Files
writer-work-flow/apps/web/components/foreshadow/ForeshadowBoard.tsx

164 lines
5.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useMemo, useState } from "react";
import { Flag, Plus } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { EmptyState } from "@/components/ui/EmptyState";
import { PageHeader } from "@/components/ui/PageHeader";
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
import {
LANES,
LANE_LABELS,
countByStatus,
groupByStatus,
type ForeshadowStatus,
} from "@/lib/foreshadow/board";
import type { BadgeVariant } from "@/lib/ui/variants";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import { KanbanColumn } from "./KanbanColumn";
import { RegisterForm } from "./RegisterForm";
interface ForeshadowBoardProps {
project: ProjectResponse;
initialItems: ForeshadowView[];
}
// 伏笔看板主体UX §6.8):四泳道 + 登记/状态变更交互。
// RSC 取全量种入Client 承载登记/转移(乐观 + 回滚 + Toast
export function ForeshadowBoard({
project,
initialItems,
}: ForeshadowBoardProps) {
const { items, busy, register, transition } = useForeshadow(initialItems);
const [registerOpen, setRegisterOpen] = useState(false);
const lanes = useMemo(() => groupByStatus(items), [items]);
const counts = useMemo(() => countByStatus(items), [items]);
const onTransition = (
code: string,
toStatus: ForeshadowStatus | null,
progressNote: string,
): void => {
void transition(code, {
projectId: project.id,
toStatus,
progressEntry: progressNote ? { note: progressNote } : null,
});
};
return (
<AppShell
title={`${project.title}`}
subtitle="伏笔看板"
projectId={project.id}
activeNav="foreshadow"
>
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col p-4 xl:h-[calc(100vh-var(--chrome,4rem))] xl:min-h-0">
<PageHeader
title="伏笔看板"
description="跟踪每条伏笔从埋设、推进到回收的状态,逾期项会单独提醒。"
actions={
// registerOpen 时隐藏头部入口,避免与展开的表单重复(全局只一个 RegisterForm
registerOpen ? undefined : (
<Button
onClick={() => setRegisterOpen(true)}
variant="primary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
)
}
/>
{registerOpen ? (
<div className="mb-4">
<RegisterForm
projectId={project.id}
busy={busy}
onRegister={register}
open={registerOpen}
onOpenChange={setRegisterOpen}
/>
</div>
) : null}
{items.length === 0 ? (
<EmptyState
icon={Flag}
title="还没有伏笔"
description="登记第一条伏笔后,这里会按待推进、推进中、已回收、已逾期四种状态自动分栏。"
action={
registerOpen ? undefined : (
<Button
onClick={() => setRegisterOpen(true)}
variant="primary"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</Button>
)
}
className="mt-6"
/>
) : (
<>
<ForeshadowBoardSummary counts={counts} />
<div className="grid flex-none grid-cols-1 gap-3 md:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-4">
{LANES.map((status) => (
<KanbanColumn
key={status}
status={status}
items={lanes[status]}
busy={busy}
onTransition={onTransition}
/>
))}
</div>
</>
)}
</div>
</AppShell>
);
}
const SUMMARY_VARIANTS: Record<ForeshadowStatus, BadgeVariant> = {
OPEN: "accent",
PARTIAL: "info",
CLOSED: "success",
OVERDUE: "warning",
};
function ForeshadowBoardSummary({
counts,
}: {
counts: Record<ForeshadowStatus, number>;
}) {
const total = LANES.reduce((sum, lane) => sum + counts[lane], 0);
return (
<section
aria-label="伏笔状态概览"
className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4"
>
{LANES.map((status) => (
<Card key={status} data-status={status} className="px-3 py-2">
<div className="flex items-center justify-between gap-2">
<Badge variant={SUMMARY_VARIANTS[status]}>
{LANE_LABELS[status]}
</Badge>
<span className="font-mono text-sm text-ink">{counts[status]}</span>
</div>
<p className="mt-1 text-2xs text-ink-soft">
{" "}
{total > 0 ? `${Math.round((counts[status] / total) * 100)}%` : "0%"}
</p>
</Card>
))}
</section>
);
}