feat(plan): show work item details and wrap titles
This commit is contained in:
parent
ab81f32cdf
commit
78fc06555d
6 changed files with 215 additions and 26 deletions
|
|
@ -86,38 +86,41 @@
|
||||||
.plan-axis-days { position: absolute; top: 34px; right: 0; left: 0; height: 33px; }
|
.plan-axis-days { position: absolute; top: 34px; right: 0; left: 0; height: 33px; }
|
||||||
.plan-axis-days span { position: absolute; top: 0; bottom: 0; display: grid; place-items: center; border-right: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
|
.plan-axis-days span { position: absolute; top: 0; bottom: 0; display: grid; place-items: center; border-right: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); color: var(--text-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
|
||||||
.plan-axis-days span.is-today { background: var(--brand-soft); color: var(--brand); font-weight: 800; }
|
.plan-axis-days span.is-today { background: var(--brand-soft); color: var(--brand); font-weight: 800; }
|
||||||
.plan-timeline-row { height: 61px; border-bottom: 1px solid var(--border-subtle); }
|
.plan-timeline-row { min-height: 61px; border-bottom: 1px solid var(--border-subtle); }
|
||||||
.plan-timeline-row:last-child { border-bottom: 0; }
|
.plan-timeline-row:last-child { border-bottom: 0; }
|
||||||
.plan-timeline-row:hover .plan-card-label { background: var(--surface); }
|
.plan-timeline-row:hover .plan-card-label { background: var(--surface); }
|
||||||
.plan-card-label { display: flex; flex-direction: column; justify-content: center; padding: 0 37px 0 17px; transition: background .15s; }
|
.plan-card-label { display: flex; flex-direction: column; justify-content: center; min-height: 61px; padding: 10px 37px 10px 17px; transition: background .15s; }
|
||||||
.plan-card-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
.plan-card-details-button { display: block; width: 100%; min-width: 0; border-radius: 5px; color: inherit; text-align: left; }
|
||||||
.plan-priority-dot { width: 7px; height: 7px; flex: none; border-radius: 50%; background: var(--plan-cyan); }
|
.plan-card-details-button:hover .plan-card-title { color: var(--brand); }
|
||||||
|
.plan-card-details-button:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; }
|
||||||
|
.plan-card-name { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
|
||||||
|
.plan-priority-dot { width: 7px; height: 7px; flex: none; margin-top: 5px; border-radius: 50%; background: var(--plan-cyan); }
|
||||||
.plan-priority-dot.is-critical { background: var(--plan-coral); }
|
.plan-priority-dot.is-critical { background: var(--plan-coral); }
|
||||||
.plan-priority-dot.is-high { background: var(--plan-amber); }
|
.plan-priority-dot.is-high { background: var(--plan-amber); }
|
||||||
.plan-priority-dot.is-low { background: var(--plan-olive); }
|
.plan-priority-dot.is-low { background: var(--plan-olive); }
|
||||||
.plan-card-title { overflow: hidden; font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
|
.plan-card-title { min-width: 0; overflow-wrap: anywhere; font-size: 12px; font-weight: 700; line-height: 1.35; }
|
||||||
.plan-card-subline { display: flex; gap: 8px; margin: 5px 0 0 15px; color: var(--text-muted); font-size: 10px; white-space: nowrap; }
|
.plan-card-subline { display: flex; flex-wrap: wrap; gap: 4px 8px; margin: 5px 0 0 15px; color: var(--text-muted); font-size: 10px; }
|
||||||
.plan-card-subline span + span::before { content: "·"; margin-right: 8px; color: var(--border-strong); }
|
.plan-card-subline span + span::before { content: "·"; margin-right: 8px; color: var(--border-strong); }
|
||||||
.plan-card-subline .plan-needs-change { color: var(--accent); font-weight: 800; }
|
.plan-card-subline .plan-needs-change { color: var(--accent); font-weight: 800; }
|
||||||
.plan-row-edit { position: absolute; right: 11px; top: 19px; display: grid; width: 24px; height: 24px; place-items: center; border-radius: 6px; color: var(--text-muted); opacity: .55; }
|
.plan-row-edit { position: absolute; right: 11px; top: 50%; display: grid; width: 24px; height: 24px; place-items: center; border-radius: 6px; color: var(--text-muted); opacity: .55; transform: translateY(-50%); }
|
||||||
.plan-row-edit:hover { background: var(--surface-raised); color: var(--brand); opacity: 1; }
|
.plan-row-edit:hover { background: var(--surface-raised); color: var(--brand); opacity: 1; }
|
||||||
.plan-row-track { position: relative; flex: none; background: color-mix(in srgb, var(--surface) 38%, var(--panel)); }
|
.plan-row-track { position: relative; min-height: 61px; flex: none; background: color-mix(in srgb, var(--surface) 38%, var(--panel)); }
|
||||||
.plan-row-track.is-weeks { background-image: repeating-linear-gradient(to right, transparent 0, transparent 30px, color-mix(in srgb, var(--border-subtle) 52%, transparent) 30px, color-mix(in srgb, var(--border-subtle) 52%, transparent) 31px); }
|
.plan-row-track.is-weeks { background-image: repeating-linear-gradient(to right, transparent 0, transparent 30px, color-mix(in srgb, var(--border-subtle) 52%, transparent) 30px, color-mix(in srgb, var(--border-subtle) 52%, transparent) 31px); }
|
||||||
.plan-track-line { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border-subtle); }
|
.plan-track-line { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--border-subtle); }
|
||||||
.plan-today-line { position: absolute; z-index: 1; top: 0; bottom: 0; width: 2px; background: var(--brand); opacity: .65; pointer-events: none; }
|
.plan-today-line { position: absolute; z-index: 1; top: 0; bottom: 0; width: 2px; background: var(--brand); opacity: .65; pointer-events: none; }
|
||||||
.plan-bar { position: absolute; z-index: 2; top: 18px; height: 25px; overflow: hidden; border-radius: 99px; background: var(--plan-cyan); box-shadow: inset 0 1px 1px #ffffff9c, 0 2px 5px #051a2d1d; color: #082938; cursor: pointer; }
|
.plan-bar { position: absolute; z-index: 2; top: calc(50% - 12px); height: 25px; overflow: hidden; border-radius: 99px; background: var(--plan-cyan); box-shadow: inset 0 1px 1px #ffffff9c, 0 2px 5px #051a2d1d; color: #082938; cursor: pointer; }
|
||||||
.plan-bar.is-critical { background: var(--plan-coral); }
|
.plan-bar.is-critical { background: var(--plan-coral); }
|
||||||
.plan-bar.is-high { background: var(--plan-amber); }
|
.plan-bar.is-high { background: var(--plan-amber); }
|
||||||
.plan-bar.is-low { background: var(--plan-olive); }
|
.plan-bar.is-low { background: var(--plan-olive); }
|
||||||
.plan-bar:disabled { cursor: default; }
|
.plan-bar:disabled { cursor: default; }
|
||||||
.plan-bar:hover:not(:disabled) { filter: brightness(1.06); transform: translateY(-1px); }
|
.plan-bar:hover:not(:disabled) { filter: brightness(1.06); transform: translateY(-1px); }
|
||||||
.plan-bar.is-approved-ghost { z-index: 1; top: 13px; height: 35px; border: 1px dashed var(--brand); background: var(--brand-soft); box-shadow: none; opacity: .9; pointer-events: none; }
|
.plan-bar.is-approved-ghost { z-index: 1; top: calc(50% - 17px); height: 35px; border: 1px dashed var(--brand); background: var(--brand-soft); box-shadow: none; opacity: .9; pointer-events: none; }
|
||||||
.plan-bar-progress { position: absolute; top: 2px; bottom: 2px; left: 2px; max-width: calc(100% - 4px); border-radius: 99px; background: #ffffff6e; pointer-events: none; }
|
.plan-bar-progress { position: absolute; top: 2px; bottom: 2px; left: 2px; max-width: calc(100% - 4px); border-radius: 99px; background: #ffffff6e; pointer-events: none; }
|
||||||
.plan-bar-title { position: relative; display: block; overflow: hidden; padding: 0 11px; font-size: 10px; font-weight: 800; line-height: 25px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
.plan-bar-title { position: relative; display: block; overflow: hidden; padding: 0 11px; font-size: 10px; font-weight: 800; line-height: 25px; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.plan-set-dates, .plan-awaiting-dates { position: absolute; top: 17px; left: 18px; display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 10px; border: 1px dashed var(--border-strong); border-radius: 99px; background: var(--panel); color: var(--text-muted); font-size: 10px; font-weight: 700; white-space: nowrap; }
|
.plan-set-dates, .plan-awaiting-dates { position: absolute; top: calc(50% - 13px); left: 18px; display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 10px; border: 1px dashed var(--border-strong); border-radius: 99px; background: var(--panel); color: var(--text-muted); font-size: 10px; font-weight: 700; white-space: nowrap; }
|
||||||
.plan-set-dates:hover { border-color: var(--brand); color: var(--brand); }
|
.plan-set-dates:hover { border-color: var(--brand); color: var(--brand); }
|
||||||
.plan-awaiting-dates { border: 0; background: transparent; font-weight: 500; }
|
.plan-awaiting-dates { border: 0; background: transparent; font-weight: 500; }
|
||||||
.plan-outside-range { position: absolute; top: 17px; left: 18px; display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 10px; border-radius: 99px; background: var(--brand-soft); color: var(--brand); font-size: 10px; font-weight: 800; white-space: nowrap; }
|
.plan-outside-range { position: absolute; top: calc(50% - 13px); left: 18px; display: inline-flex; align-items: center; gap: 6px; height: 27px; padding: 0 10px; border-radius: 99px; background: var(--brand-soft); color: var(--brand); font-size: 10px; font-weight: 800; white-space: nowrap; }
|
||||||
.plan-outside-range:hover { filter: brightness(1.08); }
|
.plan-outside-range:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
.plan-project-footer { gap: 12px; min-height: 61px; padding: 11px 19px; border-top: 1px solid var(--border-subtle); }
|
.plan-project-footer { gap: 12px; min-height: 61px; padding: 11px 19px; border-top: 1px solid var(--border-subtle); }
|
||||||
|
|
@ -169,6 +172,26 @@
|
||||||
.plan-calendar-day.is-between { border-radius: 0; background: var(--brand-soft); }
|
.plan-calendar-day.is-between { border-radius: 0; background: var(--brand-soft); }
|
||||||
.plan-calendar-day.is-selected { background: var(--brand); color: var(--brand-fg); font-weight: 800; }
|
.plan-calendar-day.is-selected { background: var(--brand); color: var(--brand-fg); font-weight: 800; }
|
||||||
.plan-dialog-bottom { align-items: flex-end; gap: 14px; padding: 20px 25px 24px; }
|
.plan-dialog-bottom { align-items: flex-end; gap: 14px; padding: 20px 25px 24px; }
|
||||||
|
.plan-item-dialog { width: min(100%, 560px); max-height: calc(100vh - 40px); overflow-y: auto; border: 1px solid var(--border-subtle); border-radius: 17px; background: var(--panel); box-shadow: 0 22px 70px #0017285c; }
|
||||||
|
.plan-item-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 24px 27px 18px; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.plan-item-top p { margin-top: 6px; color: var(--text-muted); font-size: 12px; }
|
||||||
|
.plan-item-body { padding: 22px 27px 24px; }
|
||||||
|
.plan-item-body h2 { overflow-wrap: anywhere; font-family: Georgia, "Times New Roman", serif; font-size: clamp(22px, 4vw, 29px); font-weight: 600; line-height: 1.15; }
|
||||||
|
.plan-item-tags { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 17px; }
|
||||||
|
.plan-item-tags > span { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border: 1px solid var(--border-subtle); border-radius: 99px; background: var(--surface); color: var(--text-secondary); font-size: 11px; font-weight: 700; }
|
||||||
|
.plan-item-priority i { width: 7px; height: 7px; border-radius: 50%; background: var(--plan-cyan); }
|
||||||
|
.plan-item-priority.is-critical i { background: var(--plan-coral); }
|
||||||
|
.plan-item-priority.is-high i { background: var(--plan-amber); }
|
||||||
|
.plan-item-priority.is-low i { background: var(--plan-olive); }
|
||||||
|
.plan-item-section { margin-top: 25px; }
|
||||||
|
.plan-item-section h3 { margin-bottom: 9px; color: var(--text-muted); font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
|
.plan-item-section p { overflow-wrap: anywhere; color: var(--text-secondary); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
|
||||||
|
.plan-item-section p.is-muted { color: var(--text-muted); }
|
||||||
|
.plan-item-facts { display: grid; gap: 9px; }
|
||||||
|
.plan-item-facts div { display: flex; justify-content: space-between; gap: 15px; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: 8px; background: var(--surface); font-size: 12px; }
|
||||||
|
.plan-item-facts dt { color: var(--text-muted); }
|
||||||
|
.plan-item-facts dd { color: var(--text-primary); font-weight: 700; text-align: right; }
|
||||||
|
.plan-item-footer { display: flex; justify-content: space-between; gap: 9px; padding: 18px 27px 23px; border-top: 1px solid var(--border-subtle); }
|
||||||
.plan-quick-actions { display: flex; flex-wrap: wrap; gap: 6px 11px; }
|
.plan-quick-actions { display: flex; flex-wrap: wrap; gap: 6px 11px; }
|
||||||
.plan-quick-actions button { color: var(--brand); font-size: 10px; font-weight: 750; }
|
.plan-quick-actions button { color: var(--brand); font-size: 10px; font-weight: 750; }
|
||||||
.plan-quick-actions button:hover { text-decoration: underline; }
|
.plan-quick-actions button:hover { text-decoration: underline; }
|
||||||
|
|
@ -188,6 +211,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 530px) {
|
@media (max-width: 530px) {
|
||||||
|
.plan-item-top { padding: 20px 20px 16px; }
|
||||||
|
.plan-item-body { padding: 20px; }
|
||||||
|
.plan-item-footer { flex-direction: column-reverse; padding: 16px 20px 20px; }
|
||||||
|
.plan-item-footer > * { width: 100%; }
|
||||||
.plan-toolbar-filters { align-items: stretch; flex-direction: column; }
|
.plan-toolbar-filters { align-items: stretch; flex-direction: column; }
|
||||||
.plan-project-filter select { min-width: 0; width: 100%; }
|
.plan-project-filter select { min-width: 0; width: 100%; }
|
||||||
.plan-project-filter { width: 100%; }
|
.plan-project-filter { width: 100%; }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
submitProjectPlanAction,
|
submitProjectPlanAction,
|
||||||
} from "@/app/actions/project-plan";
|
} from "@/app/actions/project-plan";
|
||||||
import { DateRangeDialog } from "@/components/planning/date-range-dialog";
|
import { DateRangeDialog } from "@/components/planning/date-range-dialog";
|
||||||
|
import { WorkItemDialog } from "@/components/planning/work-item-dialog";
|
||||||
import { useToast } from "@/components/ui/toast";
|
import { useToast } from "@/components/ui/toast";
|
||||||
import {
|
import {
|
||||||
calendarAxis,
|
calendarAxis,
|
||||||
|
|
@ -70,6 +71,7 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
|
||||||
const [zoom, setZoom] = useState<CalendarZoom>("weeks");
|
const [zoom, setZoom] = useState<CalendarZoom>("weeks");
|
||||||
const [anchor, setAnchor] = useState(todayLocalIso());
|
const [anchor, setAnchor] = useState(todayLocalIso());
|
||||||
const [editing, setEditing] = useState<EditingItem | null>(null);
|
const [editing, setEditing] = useState<EditingItem | null>(null);
|
||||||
|
const [details, setDetails] = useState<EditingItem | null>(null);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [feedbackFor, setFeedbackFor] = useState<string | null>(null);
|
const [feedbackFor, setFeedbackFor] = useState<string | null>(null);
|
||||||
const [feedback, setFeedback] = useState("");
|
const [feedback, setFeedback] = useState("");
|
||||||
|
|
@ -89,6 +91,8 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
|
||||||
const waiting = board.filter((project) => project.plan.status === "submitted").length;
|
const waiting = board.filter((project) => project.plan.status === "submitted").length;
|
||||||
const editProject = board.find((project) => project.id === editing?.projectId);
|
const editProject = board.find((project) => project.id === editing?.projectId);
|
||||||
const editCard = editProject?.cards.find((card) => card.id === editing?.cardId);
|
const editCard = editProject?.cards.find((card) => card.id === editing?.cardId);
|
||||||
|
const detailsProject = board.find((project) => project.id === details?.projectId);
|
||||||
|
const detailsCard = detailsProject?.cards.find((card) => card.id === details?.cardId);
|
||||||
|
|
||||||
function applyPlan(projectId: string, plan: ProjectPlan) {
|
function applyPlan(projectId: string, plan: ProjectPlan) {
|
||||||
setBoard((current) => current.map((project) => {
|
setBoard((current) => current.map((project) => {
|
||||||
|
|
@ -264,7 +268,11 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onJump={setAnchor}
|
onJump={setAnchor}
|
||||||
onEdit={(cardId) => setEditing({ projectId: project.id, cardId })}
|
onDetails={(cardId) => setDetails({ projectId: project.id, cardId })}
|
||||||
|
onEdit={(cardId) => {
|
||||||
|
setDetails(null);
|
||||||
|
setEditing({ projectId: project.id, cardId });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer className="plan-project-footer">
|
<footer className="plan-project-footer">
|
||||||
|
|
@ -324,6 +332,18 @@ export function PlanBoard({ projects, role }: { projects: PlanningProject[]; rol
|
||||||
onClose={() => !pending && setEditing(null)}
|
onClose={() => !pending && setEditing(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{detailsProject && detailsCard && (
|
||||||
|
<WorkItemDialog
|
||||||
|
card={detailsCard}
|
||||||
|
projectName={`${detailsProject.clientName} · ${detailsProject.name}`}
|
||||||
|
canEdit={!(role === "client_manager" && detailsProject.plan.status === "submitted")}
|
||||||
|
onClose={() => setDetails(null)}
|
||||||
|
onPlan={() => {
|
||||||
|
setDetails(null);
|
||||||
|
setEditing({ projectId: detailsProject.id, cardId: detailsCard.id });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -335,6 +355,7 @@ function ProjectTimeline({
|
||||||
sortOrder,
|
sortOrder,
|
||||||
canEdit,
|
canEdit,
|
||||||
onJump,
|
onJump,
|
||||||
|
onDetails,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
project: PlanningProject;
|
project: PlanningProject;
|
||||||
|
|
@ -343,6 +364,7 @@ function ProjectTimeline({
|
||||||
sortOrder: PlanningSort;
|
sortOrder: PlanningSort;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onJump: (date: string) => void;
|
onJump: (date: string) => void;
|
||||||
|
onDetails: (cardId: string) => void;
|
||||||
onEdit: (cardId: string) => void;
|
onEdit: (cardId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const today = todayLocalIso();
|
const today = todayLocalIso();
|
||||||
|
|
@ -404,6 +426,7 @@ function ProjectTimeline({
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
needsChange={Boolean(project.plan.feedback) && project.plan.feedbackCardId === card.id}
|
needsChange={Boolean(project.plan.feedback) && project.plan.feedbackCardId === card.id}
|
||||||
onJump={onJump}
|
onJump={onJump}
|
||||||
|
onDetails={() => onDetails(card.id)}
|
||||||
onEdit={() => onEdit(card.id)}
|
onEdit={() => onEdit(card.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -420,6 +443,7 @@ function TimelineRow({
|
||||||
canEdit,
|
canEdit,
|
||||||
needsChange,
|
needsChange,
|
||||||
onJump,
|
onJump,
|
||||||
|
onDetails,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
card: PlanningCard;
|
card: PlanningCard;
|
||||||
|
|
@ -429,8 +453,32 @@ function TimelineRow({
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
needsChange: boolean;
|
needsChange: boolean;
|
||||||
onJump: (date: string) => void;
|
onJump: (date: string) => void;
|
||||||
|
onDetails: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const detailsTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (detailsTimer.current) clearTimeout(detailsTimer.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openDetailsAfterClick(detail: number) {
|
||||||
|
if (detailsTimer.current) clearTimeout(detailsTimer.current);
|
||||||
|
if (detail === 0 || !canEdit) {
|
||||||
|
onDetails();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detailsTimer.current = setTimeout(() => {
|
||||||
|
detailsTimer.current = null;
|
||||||
|
onDetails();
|
||||||
|
}, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDateEditorOnDoubleClick() {
|
||||||
|
if (detailsTimer.current) clearTimeout(detailsTimer.current);
|
||||||
|
detailsTimer.current = null;
|
||||||
|
onEdit();
|
||||||
|
}
|
||||||
|
|
||||||
const draft = card.draftRange ? rangePixels(card.draftRange, axis) : null;
|
const draft = card.draftRange ? rangePixels(card.draftRange, axis) : null;
|
||||||
const approved = card.approvedRange && !sameRange(card.approvedRange, card.draftRange)
|
const approved = card.approvedRange && !sameRange(card.approvedRange, card.draftRange)
|
||||||
? rangePixels(card.approvedRange, axis)
|
? rangePixels(card.approvedRange, axis)
|
||||||
|
|
@ -438,18 +486,20 @@ function TimelineRow({
|
||||||
const progress = card.subtaskTotal > 0 ? Math.round((card.subtaskDone / card.subtaskTotal) * 100) : null;
|
const progress = card.subtaskTotal > 0 ? Math.round((card.subtaskDone / card.subtaskTotal) * 100) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-timeline-row" onDoubleClick={canEdit ? onEdit : undefined}>
|
<div className="plan-timeline-row" onDoubleClick={canEdit ? openDateEditorOnDoubleClick : undefined}>
|
||||||
<div className="plan-sticky-label plan-card-label">
|
<div className="plan-sticky-label plan-card-label">
|
||||||
<div className="plan-card-name">
|
<button type="button" className="plan-card-details-button" onClick={(event) => openDetailsAfterClick(event.detail)} aria-label={`View details for ${card.title}`}>
|
||||||
<span className={`plan-priority-dot is-${card.priority}`} />
|
<span className="plan-card-name">
|
||||||
<span className="plan-card-title" title={card.title}>{card.title}</span>
|
<span className={`plan-priority-dot is-${card.priority}`} />
|
||||||
</div>
|
<span className="plan-card-title">{card.title}</span>
|
||||||
<div className="plan-card-subline">
|
</span>
|
||||||
<span>{card.columnName}</span>
|
<span className="plan-card-subline">
|
||||||
{card.number > 0 && <span>#{card.number}</span>}
|
<span>{card.columnName}</span>
|
||||||
{progress !== null && <span>{progress}% complete</span>}
|
{card.number > 0 && <span>#{card.number}</span>}
|
||||||
{needsChange && <span className="plan-needs-change">Needs a change</span>}
|
{progress !== null && <span>{progress}% complete</span>}
|
||||||
</div>
|
{needsChange && <span className="plan-needs-change">Needs a change</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
{canEdit && <button type="button" className="plan-row-edit" onClick={onEdit} aria-label={`Plan dates for ${card.title}`}><Pencil size={14} /></button>}
|
{canEdit && <button type="button" className="plan-row-edit" onClick={onEdit} aria-label={`Plan dates for ${card.title}`}><Pencil size={14} /></button>}
|
||||||
</div>
|
</div>
|
||||||
<div className={`plan-row-track is-${zoom}`} style={{ width: axis.width }}>
|
<div className={`plan-row-track is-${zoom}`} style={{ width: axis.width }}>
|
||||||
|
|
|
||||||
108
src/components/planning/work-item-dialog.tsx
Normal file
108
src/components/planning/work-item-dialog.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowUpRight, CalendarDays, X } from "lucide-react";
|
||||||
|
|
||||||
|
import type { PlanningCard } from "@/lib/planning/load";
|
||||||
|
|
||||||
|
const priorityLabel: Record<PlanningCard["priority"], string> = {
|
||||||
|
critical: "Critical priority",
|
||||||
|
high: "High priority",
|
||||||
|
medium: "Medium priority",
|
||||||
|
low: "Low priority",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateText = (value: string) =>
|
||||||
|
new Date(`${value}T00:00:00.000Z`).toLocaleDateString("en", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function WorkItemDialog({
|
||||||
|
card,
|
||||||
|
projectName,
|
||||||
|
canEdit,
|
||||||
|
onClose,
|
||||||
|
onPlan,
|
||||||
|
}: {
|
||||||
|
card: PlanningCard;
|
||||||
|
projectName: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onPlan: () => void;
|
||||||
|
}) {
|
||||||
|
const closeButton = useRef<HTMLButtonElement>(null);
|
||||||
|
const dialog = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousFocus = document.activeElement as HTMLElement | null;
|
||||||
|
closeButton.current?.focus();
|
||||||
|
return () => previousFocus?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
if (event.key !== "Tab" || !dialog.current) return;
|
||||||
|
const focusable = Array.from(dialog.current.querySelectorAll<HTMLElement>("button, a[href]"));
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (!first || !last) return;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="plan-dialog-backdrop" role="presentation" onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
}}>
|
||||||
|
<section ref={dialog} className="plan-item-dialog" role="dialog" aria-modal="true" aria-label={`Details for ${card.title}`}>
|
||||||
|
<header className="plan-item-top">
|
||||||
|
<div>
|
||||||
|
<span className="plan-eyebrow">WORK ITEM{card.number > 0 ? ` · #${card.number}` : ""}</span>
|
||||||
|
<p>{projectName}</p>
|
||||||
|
</div>
|
||||||
|
<button ref={closeButton} type="button" className="plan-icon-button" onClick={onClose} aria-label="Close work item details"><X size={18} /></button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="plan-item-body">
|
||||||
|
<h2>{card.title}</h2>
|
||||||
|
<div className="plan-item-tags">
|
||||||
|
<span className={`plan-item-priority is-${card.priority}`}><i />{priorityLabel[card.priority]}</span>
|
||||||
|
<span>{card.columnName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="plan-item-section">
|
||||||
|
<h3>What needs doing</h3>
|
||||||
|
<p className={card.description ? "" : "is-muted"}>{card.description || "No description has been added yet."}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="plan-item-section">
|
||||||
|
<h3>Dates and progress</h3>
|
||||||
|
<dl className="plan-item-facts">
|
||||||
|
<div><dt>Planned dates</dt><dd>{card.draftRange ? `${dateText(card.draftRange.start)} – ${dateText(card.draftRange.end)}` : "Not planned yet"}</dd></div>
|
||||||
|
{card.boardTargetDate && <div><dt>Board target</dt><dd>{dateText(card.boardTargetDate)}</dd></div>}
|
||||||
|
{card.subtaskTotal > 0 && <div><dt>Subtasks</dt><dd>{card.subtaskDone} of {card.subtaskTotal} complete</dd></div>}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="plan-item-footer">
|
||||||
|
<Link href={card.href} className="plan-secondary-button" onClick={onClose}>Open full work item <ArrowUpRight size={15} /></Link>
|
||||||
|
{canEdit && <button type="button" className="plan-primary-button" onClick={onPlan}><CalendarDays size={15} /> {card.draftRange ? "Change dates" : "Set dates"}</button>}
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ describe("planning board loader", () => {
|
||||||
column("done", "Done", true),
|
column("done", "Done", true),
|
||||||
],
|
],
|
||||||
cards: [
|
cards: [
|
||||||
card("one", "inbox", 1),
|
{ ...card("one", "inbox", 1), description: "The full client request with details." },
|
||||||
card("two", "backlog", 2),
|
card("two", "backlog", 2),
|
||||||
card("three", "todo", 3),
|
card("three", "todo", 3),
|
||||||
card("four", "progress", 4),
|
card("four", "progress", 4),
|
||||||
|
|
@ -40,5 +40,6 @@ describe("planning board loader", () => {
|
||||||
expect(board).toHaveLength(1);
|
expect(board).toHaveLength(1);
|
||||||
expect(board[0].cards.map((item) => item.id)).toEqual(["one", "two", "three", "four"]);
|
expect(board[0].cards.map((item) => item.id)).toEqual(["one", "two", "three", "four"]);
|
||||||
expect(board[0].cards.every((item) => item.draftRange === null)).toBe(true);
|
expect(board[0].cards.every((item) => item.draftRange === null)).toBe(true);
|
||||||
|
expect(board[0].cards[0].description).toBe("The full client request with details.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface PlanningCard {
|
||||||
created: string;
|
created: string;
|
||||||
number: number;
|
number: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string;
|
||||||
priority: CardRecord["priority"];
|
priority: CardRecord["priority"];
|
||||||
columnName: string;
|
columnName: string;
|
||||||
boardTargetDate: string;
|
boardTargetDate: string;
|
||||||
|
|
@ -88,6 +89,7 @@ export function buildPlanningBoard({
|
||||||
created: card.created,
|
created: card.created,
|
||||||
number: card.number ?? 0,
|
number: card.number ?? 0,
|
||||||
title: card.title,
|
title: card.title,
|
||||||
|
description: card.description ?? "",
|
||||||
priority: card.priority,
|
priority: card.priority,
|
||||||
columnName: columnsById.get(card.column)?.name ?? "",
|
columnName: columnsById.get(card.column)?.name ?? "",
|
||||||
boardTargetDate: (card.target_date ?? "").slice(0, 10),
|
boardTargetDate: (card.target_date ?? "").slice(0, 10),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ function card(id: string, number: number, priority: PlanningCard["priority"], st
|
||||||
created: `2026-09-${String(number).padStart(2, "0")} 10:00:00.000Z`,
|
created: `2026-09-${String(number).padStart(2, "0")} 10:00:00.000Z`,
|
||||||
number,
|
number,
|
||||||
title: id,
|
title: id,
|
||||||
|
description: "",
|
||||||
priority,
|
priority,
|
||||||
columnName: "To Do",
|
columnName: "To Do",
|
||||||
boardTargetDate: "",
|
boardTargetDate: "",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue