chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,788 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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 formatCents(cents: number | null | undefined): string {
|
||||
if (cents == null) return "-";
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
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<string>("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [editingCard, setEditingCard] = useState<EditingCard | null>(null);
|
||||
const [editingLine, setEditingLine] = useState<EditingLine | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Rate Cards</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Manage cost and billing rates per role, chapter, and seniority
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateCard}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
>
|
||||
+ New Rate Card
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
|
||||
{error}
|
||||
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* ─── Left: Card list ─────────────────────────────────────────── */}
|
||||
<div className="w-80 shrink-0">
|
||||
<div className="mb-3 space-y-2">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<select
|
||||
value={filterClientId}
|
||||
onChange={(e) => setFilterClientId(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="">All clients</option>
|
||||
{clientList.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.code ? `${c.code} — ${c.name}` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
|
||||
{!isLoading && cardList.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
{search ? "No rate cards match your search." : "No rate cards yet."}
|
||||
</div>
|
||||
)}
|
||||
{cardList.map((card) => (
|
||||
<button
|
||||
key={card.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(card.id)}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors ${
|
||||
selectedId === card.id
|
||||
? "bg-brand-50 dark:bg-brand-900/20 border-l-2 border-brand-600"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{card.name}
|
||||
</span>
|
||||
{!card.isActive && (
|
||||
<span className="text-xs text-gray-400 italic ml-2 shrink-0">inactive</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{card.currency}</span>
|
||||
<span>{card._count.lines} lines</span>
|
||||
{card.effectiveFrom && (
|
||||
<span>from {formatDate(card.effectiveFrom)}</span>
|
||||
)}
|
||||
</div>
|
||||
{card.client && (
|
||||
<div className="mt-1 text-xs text-brand-600 dark:text-brand-400">
|
||||
{card.client.code ? `${card.client.code} — ${card.client.name}` : card.client.name}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Right: Detail ───────────────────────────────────────────── */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{!selectedId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center text-gray-400">
|
||||
Select a rate card to view details, or create a new one.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedId && detail && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
{/* Card header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{detail.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{detail.currency}</span>
|
||||
{detail.clientId && (detail as unknown as RateCardRow).client && (
|
||||
<span>Client: {(detail as unknown as RateCardRow).client!.name}</span>
|
||||
)}
|
||||
{detail.source && <span>Source: {detail.source}</span>}
|
||||
<span>
|
||||
{detail.effectiveFrom
|
||||
? formatDate(detail.effectiveFrom as unknown as string)
|
||||
: "Open start"}{" "}
|
||||
—{" "}
|
||||
{detail.effectiveTo
|
||||
? formatDate(detail.effectiveTo as unknown as string)
|
||||
: "Open end"}
|
||||
</span>
|
||||
{!detail.isActive && (
|
||||
<span className="text-amber-600 dark:text-amber-400 font-medium">Inactive</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEditCard}
|
||||
className="px-3 py-1.5 text-sm text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{detail.isActive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeactivate(detail.id)}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 font-medium"
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReactivate(detail.id)}
|
||||
className="px-3 py-1.5 text-sm text-green-600 hover:text-green-800 font-medium"
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lines header */}
|
||||
<div className="px-6 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Rate Lines ({lines.length})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAddLine}
|
||||
className="px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-xs font-medium"
|
||||
>
|
||||
+ Add Line
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lines table */}
|
||||
<div className="overflow-x-auto">
|
||||
{lines.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
No rate lines yet. Add one to get started.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-800">
|
||||
<th className="px-4 py-2 font-medium">Role</th>
|
||||
<th className="px-4 py-2 font-medium">Chapter</th>
|
||||
<th className="px-4 py-2 font-medium">Location</th>
|
||||
<th className="px-4 py-2 font-medium">Seniority</th>
|
||||
<th className="px-4 py-2 font-medium">Work Type</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Cost Rate</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Bill Rate</th>
|
||||
<th className="px-4 py-2 font-medium text-right">Machine Rate</th>
|
||||
<th className="px-4 py-2 font-medium w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{lines.map((line) => (
|
||||
<tr key={line.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 group">
|
||||
<td className="px-4 py-2 text-gray-900 dark:text-gray-100">
|
||||
{line.role?.name ?? <span className="text-gray-400">-</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.chapter || "-"}</td>
|
||||
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.location || "-"}</td>
|
||||
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.seniority || "-"}</td>
|
||||
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{line.workType || "-"}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-gray-900 dark:text-gray-100">
|
||||
{formatCents(line.costRateCents)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-gray-700 dark:text-gray-300">
|
||||
{formatCents(line.billRateCents)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-gray-700 dark:text-gray-300">
|
||||
{formatCents(line.machineRateCents)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditLine(line)}
|
||||
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteLine(line.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Rate Card Modal ─────────────────────────────────────────────── */}
|
||||
{editingCard && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editingCard.id ? "Edit Rate Card" : "New Rate Card"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditingCard(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingCard.name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client</label>
|
||||
<select
|
||||
value={editingCard.clientId}
|
||||
onChange={(e) => setEditingCard({ ...editingCard, clientId: 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"
|
||||
>
|
||||
<option value="">-- No client --</option>
|
||||
{clientList.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.code ? `${c.code} — ${c.name}` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Currency</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingCard.currency}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingCard.source}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editingCard.effectiveFrom}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editingCard.effectiveTo}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditingCard(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveCard}
|
||||
disabled={isPending || !editingCard.name || editingCard.currency.length !== 3}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving..." : editingCard.id ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Rate Line Modal ─────────────────────────────────────────────── */}
|
||||
{editingLine && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{editingLine.id ? "Edit Rate Line" : "Add Rate Line"}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setEditingLine(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role</label>
|
||||
<select
|
||||
value={editingLine.roleId}
|
||||
onChange={(e) => setEditingLine({ ...editingLine, roleId: 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"
|
||||
>
|
||||
<option value="">-- No specific role --</option>
|
||||
{roleList.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.chapter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.location}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Seniority</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.seniority}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Work Type</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.workType}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Service Group</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingLine.serviceGroup}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cost Rate (cents)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingLine.costRateCents}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.costRateCents)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Bill Rate (cents)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingLine.billRateCents}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.billRateCents)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Machine Rate (cents)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingLine.machineRateCents}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 mt-0.5 block">= {formatCents(editingLine.machineRateCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button type="button" onClick={() => setEditingLine(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveLine}
|
||||
disabled={isPending || editingLine.costRateCents <= 0}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving..." : editingLine.id ? "Update Line" : "Add Line"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user