feat(board): column, card tile and client control presentation

This commit is contained in:
Dave 2026-09-16 10:02:29 +02:00
parent a876ad6118
commit f4568e4745
3 changed files with 239 additions and 0 deletions

View file

@ -0,0 +1,50 @@
"use client";
import { cn } from "@/lib/cn";
import type { ColumnRecord } from "@/lib/pb/types";
export function BoardColumn({
column,
count,
isOver,
children,
setDroppableRef,
}: {
column: ColumnRecord;
count: number;
isOver?: boolean;
children: React.ReactNode;
setDroppableRef?: (element: HTMLElement | null) => void;
}) {
return (
<section
aria-label={column.name}
className="flex w-[288px] shrink-0 flex-col"
>
<header className="flex items-center gap-2 px-1 pb-2.5">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
{column.name}
</h2>
<span className="tabular rounded-full bg-surface-raised px-1.5 py-0.5 text-[11px] font-medium text-text-muted">
{count}
</span>
</header>
<div
ref={setDroppableRef}
className={cn(
"flex min-h-32 flex-1 flex-col gap-2 rounded-[var(--radius-tile)] bg-surface p-2 transition-colors",
isOver && "bg-brand-soft"
)}
>
{children}
{count === 0 && (
<p className="px-1 py-6 text-center text-xs text-text-muted">
{column.is_inbox ? "No requests" : "Nothing here"}
</p>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,77 @@
"use client";
import { forwardRef } from "react";
import { Inbox, CalendarDays } from "lucide-react";
import { Pill } from "@/components/ui/pill";
import { cn } from "@/lib/cn";
import { classifyDueDate, formatDueDate } from "@/lib/board/dates";
import type { CardRecord, Priority } from "@/lib/pb/types";
const PRIORITY_TONE: Record<Priority, "critical" | "high" | "medium" | "low"> = {
critical: "critical",
high: "high",
medium: "medium",
low: "low",
};
const PRIORITY_LABEL: Record<Priority, string> = {
critical: "Critical",
high: "High",
medium: "Medium",
low: "Low",
};
export const CardTile = forwardRef<
HTMLDivElement,
{
card: CardRecord;
dragging?: boolean;
className?: string;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
>(function CardTile({ card, dragging, className, children, ...props }, ref) {
const due = classifyDueDate(card.target_date);
const dueLabel = formatDueDate(card.target_date);
return (
<div
ref={ref}
className={cn(
"rounded-[var(--radius-tile)] border border-border-subtle bg-panel p-3",
"shadow-[var(--shadow-tile)] transition-shadow",
dragging && "shadow-[var(--shadow-drag)] opacity-90",
className
)}
{...props}
>
<p className="text-[13px] font-medium leading-[1.45] text-text-primary">
{card.title}
</p>
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
<Pill tone={PRIORITY_TONE[card.priority]}>
{PRIORITY_LABEL[card.priority]}
</Pill>
{dueLabel && (
// The warm accent's second sanctioned use: an overdue date. Anything
// not yet late stays neutral, or "late" stops registering.
<Pill tone={due === "overdue" ? "accent" : "neutral"}>
<CalendarDays size={11} strokeWidth={2} />
<span className="tabular">{dueLabel}</span>
</Pill>
)}
{card.created_by_role === "client" && !card.accepted && (
<Pill tone="brand">
<Inbox size={11} strokeWidth={2} />
Requested
</Pill>
)}
</div>
{children}
</div>
);
});

View file

@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import {
Dropdown,
DropdownContent,
DropdownItem,
DropdownLabel,
DropdownTrigger,
} from "@/components/ui/dropdown";
import type { CardRecord, Priority } from "@/lib/pb/types";
const PRIORITIES: Array<{ value: Priority; label: string }> = [
{ value: "critical", label: "Critical" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
];
/**
* The only two fields a client manager may change.
*
* Both go through PATCH /api/client/cards/:id, never straight to PocketBase.
* Their token cannot write to the cards collection at all — see Plan 1a — so
* a direct SDK call from here would simply fail. The route is the path.
*/
export function ClientCardControls({
card,
onPatched,
}: {
card: CardRecord;
onPatched: (card: CardRecord) => void;
}) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function patch(body: Record<string, unknown>) {
setSaving(true);
setError(null);
const previous = { ...card };
onPatched({ ...card, ...body } as CardRecord);
const response = await fetch(`/api/client/cards/${card.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
setSaving(false);
if (!response.ok) {
onPatched(previous);
setError("Could not save");
return;
}
const { card: updated } = await response.json();
onPatched({ ...card, ...updated } as CardRecord);
}
const currentPriority =
PRIORITIES.find((p) => p.value === card.priority) ?? PRIORITIES[2];
return (
<div className="mt-3 flex items-center gap-2 border-t border-border-subtle pt-2.5">
<Dropdown>
<DropdownTrigger asChild>
<button
type="button"
disabled={saving}
className="rounded-md px-1.5 py-1 text-[11px] font-medium text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
{currentPriority.label}
</button>
</DropdownTrigger>
<DropdownContent>
<DropdownLabel>Priority</DropdownLabel>
{PRIORITIES.map((option) => (
<DropdownItem
key={option.value}
active={option.value === card.priority}
onSelect={() => patch({ priority: option.value })}
>
{option.label}
</DropdownItem>
))}
</DropdownContent>
</Dropdown>
<label className="ml-auto flex items-center gap-1.5">
<span className="sr-only">Target date</span>
<input
type="date"
disabled={saving}
value={card.target_date ? card.target_date.slice(0, 10) : ""}
onChange={(event) =>
patch({ target_date: event.target.value || null })
}
className="tabular rounded-md border border-border-subtle bg-surface px-1.5 py-1 text-[11px] text-text-secondary disabled:opacity-50"
/>
</label>
{error && (
<span role="alert" className="text-[11px] text-accent">
{error}
</span>
)}
</div>
);
}