"use client"; import { useState } from "react"; import { formatCents } from "~/lib/format.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; // ─── Local types ──────────────────────────────────────────────────────────── type RateCardRow = { id: string; name: string; currency: string; effectiveFrom: string | null; effectiveTo: string | null; source: string | null; isActive: boolean; clientId: string | null; client: { id: string; name: string; code: string | null } | null; _count: { lines: number }; }; type RateCardLine = { id: string; rateCardId: string; roleId: string | null; chapter: string | null; location: string | null; seniority: string | null; workType: string | null; serviceGroup: string | null; costRateCents: number; billRateCents: number | null; machineRateCents: number | null; attributes: unknown; role: { id: string; name: string; color: string | null } | null; createdAt: string; updatedAt: string; }; type ClientOption = { id: string; name: string; code: string | null; }; type EditingCard = { id?: string; name: string; currency: string; effectiveFrom: string; effectiveTo: string; source: string; clientId: string; }; type EditingLine = { id?: string; roleId: string; chapter: string; location: string; seniority: string; workType: string; serviceGroup: string; costRateCents: number; billRateCents: number; machineRateCents: number; }; const emptyCard: EditingCard = { name: "", currency: "EUR", effectiveFrom: "", effectiveTo: "", source: "", clientId: "", }; const emptyLine: EditingLine = { roleId: "", chapter: "", location: "", seniority: "", workType: "", serviceGroup: "", costRateCents: 0, billRateCents: 0, machineRateCents: 0, }; function formatDate(d: string | null | undefined): string { if (!d) return "-"; return new Date(d).toLocaleDateString("de-DE"); } // ─── Component ────────────────────────────────────────────────────────────── export function RateCardsClient() { const [search, setSearch] = useState(""); const [filterClientId, setFilterClientId] = useState(""); const [selectedId, setSelectedId] = useState(null); const [editingCard, setEditingCard] = useState(null); const [editingLine, setEditingLine] = useState(null); const [error, setError] = useState(null); const utils = trpc.useUtils(); // ─── Queries ──────────────────────────────────────────────────────────── const { data: cards, isLoading } = trpc.rateCard.list.useQuery( { search: search || undefined, ...(filterClientId ? { clientId: filterClientId } : {}), }, ); const { data: detail } = trpc.rateCard.getById.useQuery( { id: selectedId! }, { enabled: !!selectedId }, ); const { data: roles } = trpc.role.list.useQuery({}); const { data: clientsData } = trpc.clientEntity.list.useQuery({}); // ─── Mutations ────────────────────────────────────────────────────────── const invalidateAll = () => { void utils.rateCard.list.invalidate(); if (selectedId) void utils.rateCard.getById.invalidate({ id: selectedId }); }; // Use bare useMutation() to avoid TS2589 deep inference (see LEARNINGS.md) const createMut = trpc.rateCard.create.useMutation(); const updateMut = trpc.rateCard.update.useMutation(); const deactivateMut = trpc.rateCard.deactivate.useMutation(); const addLineMut = trpc.rateCard.addLine.useMutation(); const updateLineMut = trpc.rateCard.updateLine.useMutation(); const deleteLineMut = trpc.rateCard.deleteLine.useMutation(); // ─── Handlers ─────────────────────────────────────────────────────────── function openCreateCard() { setEditingCard({ ...emptyCard }); setError(null); } function openEditCard() { if (!detail) return; setEditingCard({ id: detail.id, name: detail.name, currency: detail.currency, effectiveFrom: detail.effectiveFrom ? new Date(detail.effectiveFrom).toISOString().slice(0, 10) : "", effectiveTo: detail.effectiveTo ? new Date(detail.effectiveTo).toISOString().slice(0, 10) : "", source: detail.source ?? "", clientId: detail.clientId ?? "", }); setError(null); } async function handleSaveCard() { if (!editingCard) return; setError(null); try { if (editingCard.id) { await updateMut.mutateAsync({ id: editingCard.id, data: { name: editingCard.name, currency: editingCard.currency, ...(editingCard.effectiveFrom ? { effectiveFrom: new Date(editingCard.effectiveFrom) } : { effectiveFrom: null }), ...(editingCard.effectiveTo ? { effectiveTo: new Date(editingCard.effectiveTo) } : { effectiveTo: null }), source: editingCard.source || null, clientId: editingCard.clientId || null, }, }); invalidateAll(); setEditingCard(null); } else { const created = await createMut.mutateAsync({ name: editingCard.name, currency: editingCard.currency, ...(editingCard.effectiveFrom ? { effectiveFrom: new Date(editingCard.effectiveFrom) } : {}), ...(editingCard.effectiveTo ? { effectiveTo: new Date(editingCard.effectiveTo) } : {}), ...(editingCard.source ? { source: editingCard.source } : {}), ...(editingCard.clientId ? { clientId: editingCard.clientId } : {}), lines: [], }); invalidateAll(); setEditingCard(null); setSelectedId(created.id); } } catch (e) { setError(e instanceof Error ? e.message : "Failed to save rate card"); } } function openAddLine() { setEditingLine({ ...emptyLine }); setError(null); } function openEditLine(line: RateCardLine) { setEditingLine({ id: line.id, roleId: line.roleId ?? "", chapter: line.chapter ?? "", location: line.location ?? "", seniority: line.seniority ?? "", workType: line.workType ?? "", serviceGroup: line.serviceGroup ?? "", costRateCents: line.costRateCents, billRateCents: line.billRateCents ?? 0, machineRateCents: line.machineRateCents ?? 0, }); setError(null); } async function handleSaveLine() { if (!editingLine || !selectedId) return; setError(null); const lineData = { ...(editingLine.roleId ? { roleId: editingLine.roleId } : {}), ...(editingLine.chapter ? { chapter: editingLine.chapter } : {}), ...(editingLine.location ? { location: editingLine.location } : {}), ...(editingLine.seniority ? { seniority: editingLine.seniority } : {}), ...(editingLine.workType ? { workType: editingLine.workType } : {}), ...(editingLine.serviceGroup ? { serviceGroup: editingLine.serviceGroup } : {}), costRateCents: editingLine.costRateCents, ...(editingLine.billRateCents ? { billRateCents: editingLine.billRateCents } : {}), ...(editingLine.machineRateCents ? { machineRateCents: editingLine.machineRateCents } : {}), attributes: {}, }; try { if (editingLine.id) { await updateLineMut.mutateAsync({ lineId: editingLine.id, data: lineData }); } else { await addLineMut.mutateAsync({ rateCardId: selectedId, line: lineData }); } invalidateAll(); setEditingLine(null); } catch (e) { setError(e instanceof Error ? e.message : "Failed to save rate line"); } } async function handleDeleteLine(lineId: string) { if (!confirm("Delete this rate line?")) return; try { await deleteLineMut.mutateAsync({ lineId }); invalidateAll(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to delete rate line"); } } async function handleDeactivate(id: string) { if (!confirm("Deactivate this rate card?")) return; try { await deactivateMut.mutateAsync({ id }); invalidateAll(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to deactivate rate card"); } } async function handleReactivate(id: string) { try { await updateMut.mutateAsync({ id, data: { isActive: true } }); invalidateAll(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to reactivate rate card"); } } const isPending = createMut.isPending || updateMut.isPending || addLineMut.isPending || updateLineMut.isPending || deleteLineMut.isPending; const cardList = (cards ?? []) as unknown as RateCardRow[]; const lines = ((detail?.lines ?? []) as unknown as RateCardLine[]); const roleList = (roles ?? []) as unknown as { id: string; name: string; color: string | null }[]; const clientList = (clientsData ?? []) as unknown as ClientOption[]; // ─── Render ───────────────────────────────────────────────────────────── return (
{/* Header */}

Rate Cards

Manage cost and billing rates per role, chapter, and seniority

{/* Error banner */} {error && (
{error}
)}
{/* ─── Left: Card list ─────────────────────────────────────────── */}
setSearch(e.target.value)} placeholder="Search rate cards..." className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full bg-white dark:bg-gray-900 dark:text-gray-100" />
{isLoading &&
Loading...
} {!isLoading && cardList.length === 0 && (
{search ? "No rate cards match your search." : "No rate cards yet."}
)} {cardList.map((card) => ( ))}
{/* ─── Right: Detail ───────────────────────────────────────────── */}
{!selectedId && (
Select a rate card to view details, or create a new one.
)} {selectedId && detail && (
{/* Card header */}

{detail.name}

{detail.currency} {detail.clientId && (detail as unknown as RateCardRow).client && ( Client: {(detail as unknown as RateCardRow).client!.name} )} {detail.source && Source: {detail.source}} {detail.effectiveFrom ? formatDate(detail.effectiveFrom as unknown as string) : "Open start"}{" "} —{" "} {detail.effectiveTo ? formatDate(detail.effectiveTo as unknown as string) : "Open end"} {!detail.isActive && ( Inactive )}
{detail.isActive ? ( ) : ( )}
{/* Lines header */}

Rate Lines ({lines.length})

{/* Lines table */}
{lines.length === 0 ? (
No rate lines yet. Add one to get started.
) : ( {lines.map((line) => ( ))}
Role Chapter Location Seniority Work Type Cost Rate Bill Rate Machine Rate
{line.role?.name ?? -} {line.chapter || "-"} {line.location || "-"} {line.seniority || "-"} {line.workType || "-"} {formatCents(line.costRateCents)} {formatCents(line.billRateCents)} {formatCents(line.machineRateCents)}
)}
)}
{/* ─── Rate Card Modal ─────────────────────────────────────────────── */} {editingCard && (

{editingCard.id ? "Edit Rate Card" : "New Rate Card"}

setEditingCard({ ...editingCard, name: e.target.value })} placeholder="e.g. Standard 2026" className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEditingCard({ ...editingCard, currency: e.target.value.toUpperCase() })} placeholder="EUR" maxLength={3} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono" />
setEditingCard({ ...editingCard, source: e.target.value })} placeholder="e.g. Finance dept" className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEditingCard({ ...editingCard, effectiveFrom: e.target.value })} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEditingCard({ ...editingCard, effectiveTo: e.target.value })} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
)} {/* ─── Rate Line Modal ─────────────────────────────────────────────── */} {editingLine && (

{editingLine.id ? "Edit Rate Line" : "Add Rate Line"}

setEditingLine({ ...editingLine, chapter: e.target.value })} placeholder="e.g. Animation" className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEditingLine({ ...editingLine, location: e.target.value })} placeholder="e.g. Munich" className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEditingLine({ ...editingLine, seniority: e.target.value })} placeholder="e.g. Senior" className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEditingLine({ ...editingLine, workType: e.target.value })} placeholder="e.g. Onsite" className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEditingLine({ ...editingLine, serviceGroup: e.target.value })} placeholder="e.g. Post Production" className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" />
setEditingLine({ ...editingLine, costRateCents: parseInt(e.target.value) || 0 })} min={0} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono" /> = {formatCents(editingLine.costRateCents)}
setEditingLine({ ...editingLine, billRateCents: parseInt(e.target.value) || 0 })} min={0} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono" /> = {formatCents(editingLine.billRateCents)}
setEditingLine({ ...editingLine, machineRateCents: parseInt(e.target.value) || 0 })} min={0} className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono" /> = {formatCents(editingLine.machineRateCents)}
)}
); }