227 lines
8.8 KiB
TypeScript
227 lines
8.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Trash2, Pencil, ChevronUp, ChevronDown } from "lucide-react";
|
|
import { calcLineItem, formatCurrency } from "@/lib/calculations";
|
|
import { LineItemDialog } from "./line-item-dialog";
|
|
import { updateLineItem } from "@/actions/shots-assets";
|
|
import { toast } from "sonner";
|
|
|
|
interface LineItem {
|
|
id: string;
|
|
taskName: string;
|
|
artistRole: string | null;
|
|
quantityHours: any;
|
|
dailyRate: any;
|
|
hoursPerDay: any;
|
|
numArtists: any;
|
|
numDays: any;
|
|
manualHourlyRate: any;
|
|
manualSubtotal: any;
|
|
markupPercent: any;
|
|
discountPercent: any;
|
|
taxPercent: any;
|
|
notes: string | null;
|
|
rateCard: any;
|
|
}
|
|
|
|
interface Props {
|
|
lineItems: LineItem[];
|
|
currency: string;
|
|
rateCards: any[];
|
|
complexityMultiplier: string;
|
|
parentId: string;
|
|
parentType: "shot" | "asset" | "supervision" | "previs";
|
|
jobId: string;
|
|
onDelete: (id: string) => void;
|
|
onUpdate: (item: any) => void;
|
|
}
|
|
|
|
export function LineItemsTable({
|
|
lineItems,
|
|
currency,
|
|
rateCards,
|
|
complexityMultiplier,
|
|
parentId,
|
|
parentType,
|
|
jobId,
|
|
onDelete,
|
|
onUpdate,
|
|
}: Props) {
|
|
const [editItem, setEditItem] = useState<LineItem | null>(null);
|
|
const [adjustingHours, setAdjustingHours] = useState<Set<string>>(new Set());
|
|
|
|
const handleAdjustHours = async (li: LineItem, delta: number) => {
|
|
const newHours = Math.max(0, Number(li.quantityHours) + delta);
|
|
setAdjustingHours((prev) => new Set(prev).add(li.id));
|
|
try {
|
|
const result = await updateLineItem(
|
|
li.id,
|
|
jobId,
|
|
{
|
|
taskName: li.taskName,
|
|
artistRole: li.artistRole ?? undefined,
|
|
rateCardId: li.rateCard?.id,
|
|
quantityHours: newHours,
|
|
dailyRate: Number(li.dailyRate),
|
|
hoursPerDay: Number(li.hoursPerDay),
|
|
numArtists: Number(li.numArtists),
|
|
numDays: Number(li.numDays),
|
|
manualHourlyRate: li.manualHourlyRate != null ? Number(li.manualHourlyRate) : null,
|
|
manualSubtotal: li.manualSubtotal != null ? Number(li.manualSubtotal) : null,
|
|
markupPercent: Number(li.markupPercent),
|
|
discountPercent: Number(li.discountPercent),
|
|
taxPercent: Number(li.taxPercent),
|
|
notes: li.notes ?? undefined,
|
|
},
|
|
complexityMultiplier
|
|
);
|
|
onUpdate(result.lineItem);
|
|
} catch {
|
|
toast.error("Failed to update hours");
|
|
} finally {
|
|
setAdjustingHours((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(li.id);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUpdate = async (data: any) => {
|
|
if (!editItem) return;
|
|
try {
|
|
const result = await updateLineItem(editItem.id, jobId, data, complexityMultiplier);
|
|
onUpdate(result.lineItem);
|
|
setEditItem(null);
|
|
toast.success("Line item updated");
|
|
} catch {
|
|
toast.error("Failed to update line item");
|
|
}
|
|
};
|
|
|
|
if (lineItems.length === 0) {
|
|
return (
|
|
<div className="px-4 py-6 text-center text-sm text-zinc-500">
|
|
No tasks yet. Add a task below.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="border-b border-zinc-800 text-zinc-500 uppercase tracking-wider">
|
|
<th className="px-4 py-2 text-left">Task</th>
|
|
<th className="px-4 py-2 text-left">Role</th>
|
|
<th className="px-4 py-2 text-right">Hours</th>
|
|
<th className="px-4 py-2 text-right">Day Rate</th>
|
|
<th className="px-4 py-2 text-right">Artists</th>
|
|
<th className="px-4 py-2 text-right">Days</th>
|
|
<th className="px-4 py-2 text-right">Subtotal</th>
|
|
<th className="px-4 py-2 text-right">Total</th>
|
|
<th className="px-4 py-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{lineItems.map((li) => {
|
|
const calc = calcLineItem(
|
|
{
|
|
quantityHours: li.quantityHours?.toString() ?? "0",
|
|
dailyRate: li.dailyRate?.toString() ?? "0",
|
|
hoursPerDay: li.hoursPerDay?.toString() ?? "8",
|
|
numArtists: li.numArtists?.toString() ?? "1",
|
|
numDays: li.numDays?.toString() ?? "0",
|
|
manualHourlyRate: li.manualHourlyRate?.toString(),
|
|
manualSubtotal: li.manualSubtotal?.toString(),
|
|
markupPercent: li.markupPercent?.toString() ?? "0",
|
|
discountPercent: li.discountPercent?.toString() ?? "0",
|
|
taxPercent: li.taxPercent?.toString() ?? "0",
|
|
},
|
|
complexityMultiplier
|
|
);
|
|
return (
|
|
<tr key={li.id} className="border-b border-zinc-800/30 hover:bg-zinc-800/20 transition-colors">
|
|
<td className="px-4 py-2 font-medium text-white">{li.taskName}</td>
|
|
<td className="px-4 py-2 text-zinc-400">{li.artistRole ?? "—"}</td>
|
|
<td className="px-4 py-2 text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<div className="flex flex-col">
|
|
<button
|
|
onClick={() => handleAdjustHours(li, 0.5)}
|
|
disabled={adjustingHours.has(li.id)}
|
|
className="text-zinc-500 hover:text-amber-400 disabled:opacity-30 leading-none"
|
|
>
|
|
<ChevronUp className="h-2.5 w-2.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleAdjustHours(li, -0.5)}
|
|
disabled={adjustingHours.has(li.id) || Number(li.quantityHours) <= 0}
|
|
className="text-zinc-500 hover:text-amber-400 disabled:opacity-30 leading-none"
|
|
>
|
|
<ChevronDown className="h-2.5 w-2.5" />
|
|
</button>
|
|
</div>
|
|
<span className="font-mono text-zinc-300 min-w-10 text-right">
|
|
{Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2 text-right text-zinc-300 font-mono">
|
|
{Number(li.dailyRate) > 0 ? formatCurrency(Number(li.dailyRate), currency) : "—"}
|
|
</td>
|
|
<td className="px-4 py-2 text-right text-zinc-300 font-mono">{Number(li.numArtists)}</td>
|
|
<td className="px-4 py-2 text-right text-zinc-300 font-mono">
|
|
{Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}
|
|
</td>
|
|
<td className="px-4 py-2 text-right text-zinc-300 font-mono">{formatCurrency(calc.subtotal, currency)}</td>
|
|
<td className="px-4 py-2 text-right font-mono font-semibold text-white">{formatCurrency(calc.total, currency)}</td>
|
|
<td className="px-4 py-2">
|
|
<div className="flex gap-1 justify-end">
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItem(li)}>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-400 hover:text-red-300" onClick={() => onDelete(li.id)}>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{editItem && (
|
|
<LineItemDialog
|
|
open={!!editItem}
|
|
onOpenChange={(v) => !v && setEditItem(null)}
|
|
onSubmit={handleUpdate}
|
|
rateCards={rateCards}
|
|
initialData={{
|
|
taskName: editItem.taskName,
|
|
artistRole: editItem.artistRole ?? undefined,
|
|
rateCardId: editItem.rateCard?.id,
|
|
quantityHours: Number(editItem.quantityHours),
|
|
dailyRate: Number(editItem.dailyRate),
|
|
hoursPerDay: Number(editItem.hoursPerDay),
|
|
numArtists: Number(editItem.numArtists),
|
|
numDays: Number(editItem.numDays),
|
|
manualHourlyRate: editItem.manualHourlyRate != null ? Number(editItem.manualHourlyRate) : null,
|
|
manualSubtotal: editItem.manualSubtotal != null ? Number(editItem.manualSubtotal) : null,
|
|
markupPercent: Number(editItem.markupPercent),
|
|
discountPercent: Number(editItem.discountPercent),
|
|
taxPercent: Number(editItem.taxPercent),
|
|
notes: editItem.notes ?? undefined,
|
|
}}
|
|
defaultTasks={[]}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|