093e13b88f
- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata
Co-Authored-By: claude-flow <ruv@ruv.net>
786 lines
39 KiB
TypeScript
786 lines
39 KiB
TypeScript
"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<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"><span className="flex items-center">Role <InfoTooltip content="The job role this rate applies to. Leave empty if the rate is defined by chapter/seniority instead." /></span></th>
|
|
<th className="px-4 py-2 font-medium"><span className="flex items-center">Chapter <InfoTooltip content="The production discipline (e.g. Animation, Compositing). Narrows which allocations use this rate." /></span></th>
|
|
<th className="px-4 py-2 font-medium"><span className="flex items-center">Location <InfoTooltip content="Geographic location filter. Used when rates vary by region (e.g. Munich vs. Barcelona)." /></span></th>
|
|
<th className="px-4 py-2 font-medium"><span className="flex items-center">Seniority <InfoTooltip content="Experience level (Junior, Mid, Senior, etc.). Higher seniority typically has higher rates." /></span></th>
|
|
<th className="px-4 py-2 font-medium"><span className="flex items-center">Work Type <InfoTooltip content="Type of work arrangement (e.g. Onsite, Remote). Can affect rate pricing." /></span></th>
|
|
<th className="px-4 py-2 font-medium text-right"><span className="flex items-center justify-end">Cost Rate <InfoTooltip content="Internal hourly cost in cents. This is what the company actually pays. Used in budget calculations and profitability analysis." /></span></th>
|
|
<th className="px-4 py-2 font-medium text-right"><span className="flex items-center justify-end">Bill Rate <InfoTooltip content="External hourly billing rate in cents. This is what the client is charged. The difference between bill rate and cost rate is the margin." /></span></th>
|
|
<th className="px-4 py-2 font-medium text-right"><span className="flex items-center justify-end">Machine Rate <InfoTooltip content="Hourly cost for compute/render resources in cents. Added on top of personnel costs for roles that require heavy rendering." /></span></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="A descriptive name for this rate card, typically including the year or client 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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client <InfoTooltip content="Optionally tie this rate card to a specific client. Client-specific cards override the default rates for that client's projects." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Currency <InfoTooltip content="3-letter ISO currency code (e.g. EUR, USD). All rates in this card use this 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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Source <InfoTooltip content="Where these rates came from (e.g. Finance dept, Client contract). For documentation only." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective From <InfoTooltip content="Start date of this rate card's validity. Allocations before this date will not use these rates." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Effective To <InfoTooltip content="End date of this rate card's validity. Leave empty for open-ended validity." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role <InfoTooltip content="The job role this rate line applies to. Rates are matched to allocations by role, chapter, seniority, and location." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Chapter <InfoTooltip content="Production discipline this rate applies to." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Location <InfoTooltip content="Geographic location for region-specific rate pricing." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Seniority <InfoTooltip content="Experience level filter for this rate line." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Work Type <InfoTooltip content="Work arrangement type (e.g. Onsite, Remote)." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Service Group <InfoTooltip content="Broad service category (e.g. Post Production, VFX). Used for grouping rates in reports." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Cost Rate (cents) <InfoTooltip content="Internal hourly cost in cents. E.g. 7500 = 75.00 EUR/h. This is the company's actual cost and flows into budget calculations." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Bill Rate (cents) <InfoTooltip content="Hourly rate charged to the client in cents. The margin is bill rate minus cost rate." /></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="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Machine Rate (cents) <InfoTooltip content="Hourly compute/render cost in cents. Added on top of personnel costs for render-heavy roles." /></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>
|
|
);
|
|
}
|