Add full project files

This commit is contained in:
twotalesanimation
2026-08-18 12:11:20 +02:00
parent 8ce915bcac
commit 95ed30812e
112 changed files with 21868 additions and 225 deletions
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { GripVertical, ChevronDown, ChevronRight, Trash2, Pencil, Plus } from "lucide-react";
import { updateAsset, deleteLineItem, createLineItem } from "@/actions/shots-assets";
import { toast } from "sonner";
import { LineItemsTable } from "@/components/line-items/line-items-table";
import { AssetDialog } from "./asset-dialog";
import { LineItemDialog } from "@/components/line-items/line-item-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
interface Asset {
id: string;
assetName: string;
assetType: string | null;
description: string | null;
complexityMultiplier: any;
notes: string | null;
lineItems: any[];
}
interface Props {
asset: Asset;
jobId: string;
currency: string;
rateCards: any[];
onDelete: (id: string) => void;
onUpdate: (asset: any) => void;
}
export function AssetCard({ asset, jobId, currency, rateCards, onDelete, onUpdate }: Props) {
const [expanded, setExpanded] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [addLineOpen, setAddLineOpen] = useState(false);
const [lineItems, setLineItems] = useState(asset.lineItems);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: asset.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const totals = sumLineItems(
lineItems.map((li) =>
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",
}, asset.complexityMultiplier?.toString() ?? "1")
)
);
const handleEdit = async (data: any) => {
try {
const result = await updateAsset(asset.id, jobId, data);
onUpdate(result.asset);
setEditOpen(false);
toast.success("Asset updated");
} catch {
toast.error("Failed to update asset");
}
};
const handleAddLineItem = async (data: any) => {
try {
const result = await createLineItem(asset.id, "asset", jobId, data);
setLineItems((prev) => [...prev, result.lineItem]);
setAddLineOpen(false);
toast.success("Line item added");
} catch {
toast.error("Failed to add line item");
}
};
const handleDeleteLineItem = async (id: string) => {
try {
await deleteLineItem(id, jobId);
setLineItems((prev) => prev.filter((li) => li.id !== id));
toast.success("Line item removed");
} catch {
toast.error("Failed to delete line item");
}
};
const handleLineItemUpdate = (updated: any) => {
setLineItems((prev) => prev.map((li) => (li.id === updated.id ? updated : li)));
};
return (
<div ref={setNodeRef} style={style} className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3">
<button {...attributes} {...listeners} className="text-zinc-600 hover:text-zinc-400 cursor-grab active:cursor-grabbing">
<GripVertical className="h-4 w-4" />
</button>
<button onClick={() => setExpanded(!expanded)} className="flex items-center gap-2 flex-1 text-left">
{expanded ? <ChevronDown className="h-4 w-4 text-zinc-400" /> : <ChevronRight className="h-4 w-4 text-zinc-400" />}
<span className="font-semibold text-white">{asset.assetName}</span>
{asset.assetType && <span className="text-xs text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded">{asset.assetType}</span>}
<span className="ml-auto text-xs text-zinc-500">{lineItems.length} tasks</span>
</button>
<span className="font-mono text-sm text-amber-400 ml-4">{formatCurrency(totals.total, currency)}</span>
<div className="flex gap-1 ml-2">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setEditOpen(true)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => onDelete(asset.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{expanded && (
<div className="border-t border-zinc-800">
{(asset.description || Number(asset.complexityMultiplier) !== 1) && (
<div className="px-4 py-3 bg-zinc-800/30 text-sm text-zinc-400 flex gap-6">
{asset.description && <span>{asset.description}</span>}
{Number(asset.complexityMultiplier) !== 1 && (
<span className="text-amber-400">×{Number(asset.complexityMultiplier).toFixed(2)} complexity</span>
)}
</div>
)}
<LineItemsTable
lineItems={lineItems}
currency={currency}
rateCards={rateCards}
complexityMultiplier={asset.complexityMultiplier?.toString() ?? "1"}
parentId={asset.id}
parentType="asset"
jobId={jobId}
onDelete={handleDeleteLineItem}
onUpdate={handleLineItemUpdate}
/>
<div className="px-4 py-3 border-t border-zinc-800">
<Button size="sm" variant="outline" className="border-zinc-700 text-sm" onClick={() => setAddLineOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Add Task
</Button>
</div>
</div>
)}
<AssetDialog open={editOpen} onOpenChange={setEditOpen} onSubmit={handleEdit} initialData={{
assetName: asset.assetName,
assetType: asset.assetType ?? undefined,
complexityMultiplier: Number(asset.complexityMultiplier),
description: asset.description ?? undefined,
notes: asset.notes ?? undefined,
}} />
<LineItemDialog
open={addLineOpen}
onOpenChange={setAddLineOpen}
onSubmit={handleAddLineItem}
rateCards={rateCards}
defaultTasks={[
"Modeling","Sculpting","Retopology","Texturing","Shading","Lookdev",
"Rigging","Groom","Animation Prep","Simulation Prep","Kitbash",
"Scanning Cleanup","Photogrammetry Processing","Asset Optimization",
"Rendering","Turntables","Concept Design","Technical Setup",
]}
/>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { assetSchema, type AssetFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { ASSET_TYPES } from "@/lib/constants";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: AssetFormValues) => Promise<void>;
initialData?: Partial<AssetFormValues>;
}
export function AssetDialog({ open, onOpenChange, onSubmit, initialData }: Props) {
const {
register,
handleSubmit,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<AssetFormValues>({
resolver: zodResolver(assetSchema) as Resolver<AssetFormValues>,
defaultValues: { complexityMultiplier: 1, ...initialData },
});
const handleClose = () => { reset(); onOpenChange(false); };
const submit = async (data: AssetFormValues) => { await onSubmit(data); reset(); };
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader>
<DialogTitle>{initialData ? "Edit Asset" : "Add Asset"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label>Asset Name *</Label>
<Input {...register("assetName")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Hero Character" />
{errors.assetName && <p className="text-red-400 text-xs">{errors.assetName.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Asset Type</Label>
<Select onValueChange={(v) => setValue("assetType", v ?? undefined)} defaultValue={initialData?.assetType ?? undefined}>
<SelectTrigger className="bg-zinc-800 border-zinc-700">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{ASSET_TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Complexity Multiplier</Label>
<Input {...register("complexityMultiplier")} type="number" step="0.1" min="0.1" max="10" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input {...register("description")} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>Cancel</Button>
<Button type="submit" disabled={isSubmitting} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData ? "Save" : "Add Asset"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { createAsset, deleteAsset, reorderAssets } from "@/actions/shots-assets";
import { AssetCard } from "./asset-card";
import { AssetDialog } from "./asset-dialog";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import type { Prisma } from "@prisma/client";
type JobWithRelations = Prisma.JobGetPayload<{
include: {
client: true;
shots: { include: { lineItems: { include: { rateCard: true } } } };
assets: { include: { lineItems: { include: { rateCard: true } } } };
comments: { include: { user: true } };
history: true;
};
}>;
export function AssetsEditor({ job, rateCards }: { job: JobWithRelations; rateCards: any[] }) {
const [assets, setAssets] = useState(job.assets);
const [dialogOpen, setDialogOpen] = useState(false);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = assets.findIndex((a) => a.id === active.id);
const newIndex = assets.findIndex((a) => a.id === over.id);
const reordered = arrayMove(assets, oldIndex, newIndex);
setAssets(reordered);
await reorderAssets(job.id, reordered.map((a) => a.id));
};
const handleCreate = async (data: any) => {
try {
const result = await createAsset(job.id, data);
setAssets((prev) => [...prev, result.asset as any]);
setDialogOpen(false);
toast.success("Asset added");
} catch {
toast.error("Failed to add asset");
}
};
const handleDelete = async (assetId: string) => {
try {
await deleteAsset(assetId, job.id);
setAssets((prev) => prev.filter((a) => a.id !== assetId));
toast.success("Asset deleted");
} catch {
toast.error("Failed to delete asset");
}
};
const handleAssetUpdate = (updated: any) => {
setAssets((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Assets ({assets.length})</h2>
<Button
onClick={() => setDialogOpen(true)}
size="sm"
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
<Plus className="h-4 w-4 mr-1" /> Add Asset
</Button>
</div>
{assets.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-dashed border-zinc-700 text-zinc-500">
<p>No assets yet. Add reusable VFX assets to this quote.</p>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={assets.map((a) => a.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{assets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset as any}
jobId={job.id}
currency="ZAR"
rateCards={rateCards}
onDelete={handleDelete}
onUpdate={handleAssetUpdate}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<AssetDialog open={dialogOpen} onOpenChange={setDialogOpen} onSubmit={handleCreate} />
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { clientSchema, type ClientFormValues } from "@/lib/validations";
import { createClient, updateClient } from "@/actions/clients";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
interface Props {
initialData?: Partial<ClientFormValues>;
clientId?: string;
}
export function ClientForm({ initialData, clientId }: Props) {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ClientFormValues>({
resolver: zodResolver(clientSchema),
defaultValues: initialData ?? {},
});
const onSubmit = async (data: ClientFormValues) => {
try {
if (clientId) {
await updateClient(clientId, data);
toast.success("Client updated");
} else {
const result = await createClient(data);
toast.success("Client created");
router.push(`/clients/${result.client.id}`);
}
} catch {
toast.error("Something went wrong");
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div className="space-y-1.5">
<Label>Company Name *</Label>
<Input {...register("companyName")} className="bg-zinc-900 border-zinc-700" placeholder="e.g. Framestore" />
{errors.companyName && <p className="text-red-400 text-xs">{errors.companyName.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Contact Person</Label>
<Input {...register("contactPerson")} className="bg-zinc-900 border-zinc-700" placeholder="Full name" />
</div>
<div className="space-y-1.5">
<Label>Email</Label>
<Input {...register("email")} type="email" className="bg-zinc-900 border-zinc-700" placeholder="contact@studio.com" />
{errors.email && <p className="text-red-400 text-xs">{errors.email.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Phone</Label>
<Input {...register("phone")} className="bg-zinc-900 border-zinc-700" placeholder="+1 555 000 0000" />
</div>
<div className="space-y-1.5">
<Label>VAT Number</Label>
<Input {...register("vatNumber")} className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Company Reg. Number</Label>
<Input {...register("companyRegNumber")} className="bg-zinc-900 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Billing Address</Label>
<Textarea {...register("billingAddress")} className="bg-zinc-900 border-zinc-700" rows={3} />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-900 border-zinc-700" rows={3} />
</div>
<Button
type="submit"
disabled={isSubmitting}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{clientId ? "Save Changes" : "Create Client"}
</Button>
</form>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { deleteClient } from "@/actions/clients";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pencil, Trash2, Search, Building2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface Client {
id: string;
companyName: string;
contactPerson: string | null;
email: string | null;
phone: string | null;
_count: { jobs: number };
}
export function ClientsTable({ clients }: { clients: Client[] }) {
const [search, setSearch] = useState("");
const [deleteId, setDeleteId] = useState<string | null>(null);
const filtered = clients.filter(
(c) =>
c.companyName.toLowerCase().includes(search.toLowerCase()) ||
c.contactPerson?.toLowerCase().includes(search.toLowerCase()) ||
c.email?.toLowerCase().includes(search.toLowerCase())
);
const handleDelete = async () => {
if (!deleteId) return;
try {
await deleteClient(deleteId);
toast.success("Client deleted");
} catch {
toast.error("Failed to delete client");
}
setDeleteId(null);
};
return (
<>
<div className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
placeholder="Search clients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-zinc-900 border-zinc-700"
/>
</div>
</div>
{filtered.length === 0 ? (
<div className="text-center py-16 text-zinc-500">
<Building2 className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p>No clients found.</p>
</div>
) : (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-400 text-xs uppercase tracking-wider">
<th className="px-4 py-3 text-left">Company</th>
<th className="px-4 py-3 text-left">Contact</th>
<th className="px-4 py-3 text-left">Email</th>
<th className="px-4 py-3 text-left">Phone</th>
<th className="px-4 py-3 text-center">Jobs</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((client) => (
<tr key={client.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30 transition-colors">
<td className="px-4 py-3">
<Link href={`/clients/${client.id}`} className="font-medium text-white hover:text-amber-400 transition-colors">
{client.companyName}
</Link>
</td>
<td className="px-4 py-3 text-zinc-300">{client.contactPerson ?? "—"}</td>
<td className="px-4 py-3 text-zinc-400">{client.email ?? "—"}</td>
<td className="px-4 py-3 text-zinc-400">{client.phone ?? "—"}</td>
<td className="px-4 py-3 text-center">
<span className="text-zinc-400">{client._count.jobs}</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex gap-1 justify-end">
<Link href={`/clients/${client.id}`} className="inline-flex items-center justify-center h-7 w-7 rounded-lg hover:bg-zinc-700 transition-colors text-zinc-400 hover:text-zinc-200">
<Pencil className="h-3.5 w-3.5" />
</Link>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-400 hover:text-red-300"
onClick={() => setDeleteId(client.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-800">
<AlertDialogHeader>
<AlertDialogTitle>Delete client?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the client and all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-500">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+74
View File
@@ -0,0 +1,74 @@
import Link from "next/link";
import { format } from "date-fns";
import { Badge } from "@/components/ui/badge";
import { JOB_STATUSES } from "@/lib/constants";
interface Job {
id: string;
projectTitle: string;
projectCode: string;
status: string;
updatedAt: Date;
client: { companyName: string };
}
const statusColors: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
APPROVED: "bg-green-900/60 text-green-300",
REJECTED: "bg-red-900/60 text-red-300",
ARCHIVED: "bg-yellow-900/60 text-yellow-300",
};
export function RecentJobs({ jobs }: { jobs: Job[] }) {
if (jobs.length === 0) {
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-10 text-center text-zinc-500">
No jobs yet.{" "}
<Link href="/jobs/new" className="text-amber-400 hover:underline">
Create your first quote
</Link>
.
</div>
);
}
return (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-400 text-xs uppercase tracking-wider">
<th className="px-4 py-3 text-left">Project</th>
<th className="px-4 py-3 text-left">Client</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Updated</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr
key={job.id}
className="border-b border-zinc-800/50 hover:bg-zinc-800/40 transition-colors"
>
<td className="px-4 py-3">
<Link href={`/jobs/${job.id}`} className="font-medium text-white hover:text-amber-400 transition-colors">
{job.projectTitle}
</Link>
<div className="text-xs text-zinc-500">{job.projectCode}</div>
</td>
<td className="px-4 py-3 text-zinc-300">{job.client.companyName}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[job.status]}`}>
{JOB_STATUSES.find((s) => s.value === job.status)?.label ?? job.status}
</span>
</td>
<td className="px-4 py-3 text-zinc-500">
{format(new Date(job.updatedAt), "MMM d, yyyy")}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Briefcase, Users, FileText, CheckCircle, Clock } from "lucide-react";
interface Props {
jobCount: number;
clientCount: number;
draftCount: number;
sentCount: number;
approvedCount: number;
}
export function DashboardStats({ jobCount, clientCount, draftCount, sentCount, approvedCount }: Props) {
const stats = [
{ label: "Total Jobs", value: jobCount, icon: Briefcase, color: "text-amber-400" },
{ label: "Clients", value: clientCount, icon: Users, color: "text-blue-400" },
{ label: "Drafts", value: draftCount, icon: FileText, color: "text-zinc-400" },
{ label: "Sent", value: sentCount, icon: Clock, color: "text-yellow-400" },
{ label: "Approved", value: approvedCount, icon: CheckCircle, color: "text-green-400" },
];
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{stats.map(({ label, value, icon: Icon, color }) => (
<Card key={label} className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
<Icon className={`h-4 w-4 ${color}`} />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">{value}</div>
</CardContent>
</Card>
))}
</div>
);
}
+398
View File
@@ -0,0 +1,398 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
import { toast } from "sonner";
import { ArrowLeft, Printer, Pencil, Plus, Trash2, Send, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { recordPayment, deletePayment, deleteInvoice, updateInvoice } from "@/actions/invoices";
import { formatCurrency } from "@/lib/calculations";
import type { Prisma } from "@prisma/client";
type FullInvoice = Prisma.InvoiceGetPayload<{
include: {
client: true;
job: { include: { paymentSchedule: { include: { milestones: true } } } };
milestone: true;
payments: true;
};
}>;
const STATUS_COLORS: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
VIEWED: "bg-purple-900/60 text-purple-300",
PARTIALLY_PAID: "bg-orange-900/60 text-orange-300",
PAID: "bg-green-900/60 text-green-300",
OVERDUE: "bg-red-900/60 text-red-300",
CANCELLED: "bg-zinc-800 text-zinc-500",
};
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SENT: "Sent", VIEWED: "Viewed",
PARTIALLY_PAID: "Partially Paid", PAID: "Paid", OVERDUE: "Overdue", CANCELLED: "Cancelled",
};
const PAYMENT_METHODS = ["EFT", "CASH", "CREDIT_CARD", "BANK_TRANSFER", "CRYPTO", "OTHER"];
interface Props {
invoice: FullInvoice;
}
export function InvoiceDetail({ invoice: initialInvoice }: Props) {
const router = useRouter();
const [invoice, setInvoice] = useState(initialInvoice);
const [isPending, startTransition] = useTransition();
const [payDialogOpen, setPayDialogOpen] = useState(false);
const [payForm, setPayForm] = useState({
amount: Number(invoice.amountDue),
paymentDate: format(new Date(), "yyyy-MM-dd"),
paymentMethod: "EFT" as string,
reference: "",
notes: "",
});
const fmt = (v: number) => formatCurrency(v, "ZAR");
const amountPaid = Number(invoice.amountPaid);
const amountDue = Number(invoice.amountDue);
const totalAmount = Number(invoice.totalAmount);
const paidPercent = totalAmount > 0 ? Math.min(100, (amountPaid / totalAmount) * 100) : 0;
const handleRecordPayment = () => {
startTransition(async () => {
try {
await recordPayment(invoice.id, payForm as any);
toast.success("Payment recorded");
setPayDialogOpen(false);
router.refresh();
} catch {
toast.error("Failed to record payment");
}
});
};
const handleDeletePayment = (paymentId: string) => {
startTransition(async () => {
try {
await deletePayment(invoice.id, paymentId);
toast.success("Payment removed");
router.refresh();
} catch {
toast.error("Failed to remove payment");
}
});
};
const handleDelete = () => {
if (!confirm("Delete this invoice? This cannot be undone.")) return;
startTransition(async () => {
try {
await deleteInvoice(invoice.id);
toast.success("Invoice deleted");
router.push("/invoices");
} catch {
toast.error("Failed to delete invoice");
}
});
};
return (
<div className="p-6 space-y-6 max-w-5xl">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-1">
<Link href="/invoices" className="text-zinc-500 hover:text-zinc-300 transition-colors">
<ArrowLeft className="h-4 w-4" />
</Link>
<h1 className="text-2xl font-bold text-white font-mono">{invoice.invoiceNumber}</h1>
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-semibold ${STATUS_COLORS[invoice.status] ?? ""}`}>
{STATUS_LABELS[invoice.status] ?? invoice.status}
</span>
</div>
<p className="text-sm text-zinc-400 ml-7">
{invoice.client.companyName} · {invoice.job.projectCode}
</p>
</div>
<div className="flex items-center gap-2">
<a href={`/api/pdf/invoice/${invoice.id}`} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300">
<Printer className="h-3.5 w-3.5 mr-1.5" /> PDF
</Button>
</a>
<Link href={`/invoices/${invoice.id}/edit`}>
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300">
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
</Button>
</Link>
<Button
variant="ghost" size="sm"
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={handleDelete} disabled={isPending}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" /> Delete
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-6">
{/* Left: Invoice info */}
<div className="col-span-2 space-y-6">
{/* Billing info */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 grid grid-cols-2 gap-6">
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">Bill To</p>
<p className="font-semibold text-white">{invoice.client.companyName}</p>
{invoice.client.billingAddress && <p className="text-sm text-zinc-400 mt-1 whitespace-pre-line">{invoice.client.billingAddress}</p>}
{invoice.client.vatNumber && <p className="text-xs text-zinc-500 mt-1">VAT: {invoice.client.vatNumber}</p>}
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">Issue Date</span>
<span className="text-zinc-300">{format(new Date(invoice.issueDate), "MMM d, yyyy")}</span>
</div>
{invoice.dueDate && (
<div className="flex justify-between">
<span className="text-zinc-500">Due Date</span>
<span className="text-zinc-300">{format(new Date(invoice.dueDate), "MMM d, yyyy")}</span>
</div>
)}
{invoice.paymentTerms && (
<div className="flex justify-between">
<span className="text-zinc-500">Terms</span>
<span className="text-zinc-300">{invoice.paymentTerms}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-zinc-500">Project</span>
<Link href={`/jobs/${invoice.job.id}`} className="text-amber-400 hover:text-amber-300">
{invoice.job.projectCode}
</Link>
</div>
{invoice.milestone && (
<div className="flex justify-between">
<span className="text-zinc-500">Milestone</span>
<span className="text-zinc-300">{invoice.milestone.title}</span>
</div>
)}
</div>
</div>
{/* Services / Line Items */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<div className="px-5 py-3 border-b border-zinc-800">
<p className="text-xs text-zinc-500 uppercase tracking-wider">Services</p>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">#</th>
<th className="text-left text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">Description</th>
<th className="text-left text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">Period</th>
<th className="text-right text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">%</th>
<th className="text-right text-xs text-zinc-500 font-semibold uppercase tracking-wider px-5 py-2.5">Amount</th>
</tr>
</thead>
<tbody>
<tr>
<td className="px-5 py-3.5 text-zinc-500 align-top">1</td>
<td className="px-5 py-3.5 align-top">
<p className="font-medium text-white">
{invoice.job.projectCode} — {invoice.milestone?.title ?? "Professional VFX Services"}
</p>
{invoice.milestone?.description && (
<p className="text-xs text-zinc-400 mt-0.5">{invoice.milestone.description}</p>
)}
<p className="text-xs text-zinc-500 mt-0.5">{invoice.job.projectTitle}</p>
</td>
<td className="px-5 py-3.5 text-zinc-400 align-top whitespace-nowrap">
{invoice.milestone?.dueDate
? format(new Date(invoice.milestone.dueDate), "MMM yyyy")
: format(new Date(invoice.issueDate), "MMM yyyy")}
</td>
<td className="px-5 py-3.5 text-right text-zinc-400 align-top font-mono">
{invoice.milestone
? `${Number(invoice.milestone.percentOfTotal).toFixed(0)}%`
: "—"}
</td>
<td className="px-5 py-3.5 text-right font-mono font-semibold text-white align-top">
{fmt(Number(invoice.subtotal))}
</td>
</tr>
</tbody>
</table>
</div>
{/* Financials */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 space-y-3">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-3">Financials</p>
{[
{ label: "Subtotal", value: Number(invoice.subtotal) },
{ label: `Tax (${Number(invoice.taxPercent)}%)`, value: Number(invoice.taxAmount) },
].map(row => (
<div key={row.label} className="flex justify-between text-sm">
<span className="text-zinc-400">{row.label}</span>
<span className="font-mono text-zinc-300">{fmt(row.value)}</span>
</div>
))}
<Separator className="bg-zinc-700" />
<div className="flex justify-between font-bold">
<span className="text-white">Total</span>
<span className="font-mono text-amber-400 text-lg">{fmt(totalAmount)}</span>
</div>
</div>
{/* Notes */}
{invoice.notes && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">Notes</p>
<p className="text-sm text-zinc-300 whitespace-pre-line">{invoice.notes}</p>
</div>
)}
{/* Banking details */}
{invoice.bankingDetails && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-2">Banking Details</p>
<p className="text-sm text-zinc-300 whitespace-pre-line font-mono">{invoice.bankingDetails}</p>
</div>
)}
{/* Payment history */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">Payment History</p>
{invoice.status !== "PAID" && invoice.status !== "CANCELLED" && (
<Button size="sm" onClick={() => setPayDialogOpen(true)} className="bg-green-700 hover:bg-green-600 text-white h-7 text-xs">
<Plus className="h-3 w-3 mr-1" /> Record Payment
</Button>
)}
</div>
{invoice.payments.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-4">No payments recorded yet</p>
) : (
<div className="space-y-2">
{invoice.payments.map(p => (
<div key={p.id} className="flex items-center justify-between py-2 border-b border-zinc-800 last:border-0">
<div>
<p className="text-sm font-semibold text-green-400 font-mono">{fmt(Number(p.amount))}</p>
<p className="text-xs text-zinc-500">
{format(new Date(p.paymentDate), "MMM d, yyyy")} · {p.paymentMethod}
{p.reference && ` · ${p.reference}`}
</p>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-500 hover:text-red-400" onClick={() => handleDeletePayment(p.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Right: Payment status */}
<div className="space-y-4">
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 space-y-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">Payment Status</p>
<div>
<div className="flex justify-between text-xs text-zinc-500 mb-1">
<span>{fmt(amountPaid)} paid</span>
<span>{fmt(totalAmount)}</span>
</div>
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{ width: `${paidPercent}%` }}
/>
</div>
<p className="text-xs text-zinc-500 mt-1 text-right">{paidPercent.toFixed(0)}% paid</p>
</div>
<Separator className="bg-zinc-800" />
{[
{ label: "Invoice Total", value: fmt(totalAmount), color: "text-white" },
{ label: "Amount Paid", value: fmt(amountPaid), color: "text-green-400" },
{ label: "Balance Due", value: fmt(amountDue), color: amountDue > 0 ? "text-amber-400" : "text-green-400" },
].map(row => (
<div key={row.label} className="flex justify-between">
<span className="text-sm text-zinc-400">{row.label}</span>
<span className={`font-mono font-semibold text-sm ${row.color}`}>{row.value}</span>
</div>
))}
{invoice.status !== "PAID" && invoice.status !== "CANCELLED" && (
<Button
className="w-full bg-green-700 hover:bg-green-600 text-white"
onClick={() => setPayDialogOpen(true)}
disabled={isPending}
>
<Check className="h-4 w-4 mr-1.5" /> Record Payment
</Button>
)}
</div>
</div>
</div>
{/* Record payment dialog */}
<Dialog open={payDialogOpen} onOpenChange={setPayDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-800">
<DialogHeader>
<DialogTitle>Record Payment</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label>Amount *</Label>
<Input
type="number" min={0} step={0.01}
value={payForm.amount}
onChange={e => setPayForm(f => ({ ...f, amount: Number(e.target.value) }))}
className="bg-zinc-800 border-zinc-700 font-mono"
/>
<p className="text-xs text-zinc-500">Balance due: {fmt(amountDue)}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Payment Date *</Label>
<Input type="date" value={payForm.paymentDate} onChange={e => setPayForm(f => ({ ...f, paymentDate: e.target.value }))} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Method</Label>
<Select value={payForm.paymentMethod} onValueChange={v => setPayForm(f => ({ ...f, paymentMethod: v ?? "EFT" }))}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{PAYMENT_METHODS.map(m => <SelectItem key={m} value={m}>{m.replace("_", " ")}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label>Reference</Label>
<Input value={payForm.reference} onChange={e => setPayForm(f => ({ ...f, reference: e.target.value }))} placeholder="Payment reference / EFT number" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea value={payForm.notes} onChange={e => setPayForm(f => ({ ...f, notes: e.target.value }))} className="bg-zinc-800 border-zinc-700 resize-none" rows={2} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setPayDialogOpen(false)} className="border-zinc-700">Cancel</Button>
<Button onClick={handleRecordPayment} disabled={isPending || !payForm.amount} className="bg-green-700 hover:bg-green-600">
Record Payment
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
"use client";
import { useTransition, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { toast } from "sonner";
import Decimal from "decimal.js";
import type { Resolver } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { invoiceSchema, type InvoiceFormValues } from "@/lib/validations";
import { createInvoice, updateInvoice, nextInvoiceNumber } from "@/actions/invoices";
import { formatCurrency } from "@/lib/calculations";
import { CURRENCIES } from "@/lib/constants";
import type { Prisma } from "@prisma/client";
type ClientRow = { id: string; companyName: string };
type JobRow = { id: string; projectTitle: string; projectCode: string; clientId: string };
interface Props {
clients: ClientRow[];
jobs: JobRow[];
invoice?: Prisma.InvoiceGetPayload<Record<string, never>> | null;
}
const STATUSES = [
{ value: "DRAFT", label: "Draft" },
{ value: "SENT", label: "Sent" },
{ value: "VIEWED", label: "Viewed" },
{ value: "PARTIALLY_PAID", label: "Partially Paid" },
{ value: "PAID", label: "Paid" },
{ value: "OVERDUE", label: "Overdue" },
{ value: "CANCELLED", label: "Cancelled" },
];
export function InvoiceForm({ clients, jobs, invoice }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const today = format(new Date(), "yyyy-MM-dd");
const { register, control, watch, setValue, handleSubmit, formState: { errors } } = useForm<InvoiceFormValues>({
resolver: zodResolver(invoiceSchema) as Resolver<InvoiceFormValues>,
defaultValues: invoice
? {
invoiceNumber: invoice.invoiceNumber,
clientId: invoice.clientId,
jobId: invoice.jobId,
milestoneId: invoice.milestoneId ?? "",
status: invoice.status as any,
issueDate: format(new Date(invoice.issueDate), "yyyy-MM-dd"),
dueDate: invoice.dueDate ? format(new Date(invoice.dueDate), "yyyy-MM-dd") : "",
paymentTerms: invoice.paymentTerms ?? "",
currency: invoice.currency,
subtotal: Number(invoice.subtotal),
taxPercent: Number(invoice.taxPercent),
taxAmount: Number(invoice.taxAmount),
totalAmount: Number(invoice.totalAmount),
notes: invoice.notes ?? "",
internalNotes: invoice.internalNotes ?? "",
bankingDetails: invoice.bankingDetails ?? "",
}
: {
invoiceNumber: "",
clientId: "",
jobId: "",
status: "DRAFT",
issueDate: today,
currency: "ZAR",
subtotal: 0,
taxPercent: 15,
taxAmount: 0,
totalAmount: 0,
},
});
const subtotal = watch("subtotal") ?? 0;
const taxPercent = watch("taxPercent") ?? 0;
const currency = watch("currency") ?? "ZAR";
const clientId = watch("clientId");
// Auto-populate invoice number on new
useEffect(() => {
if (!invoice) {
nextInvoiceNumber().then(n => setValue("invoiceNumber", n));
}
}, [invoice, setValue]);
// Recalculate tax + total when subtotal or taxPercent changes
useEffect(() => {
const tax = new Decimal(subtotal || 0).mul(new Decimal(taxPercent || 0).div(100));
const total = new Decimal(subtotal || 0).add(tax);
setValue("taxAmount", tax.toDecimalPlaces(2).toNumber());
setValue("totalAmount", total.toDecimalPlaces(2).toNumber());
}, [subtotal, taxPercent, setValue]);
const filteredJobs = jobs.filter(j => !clientId || j.clientId === clientId);
const onSubmit = (data: InvoiceFormValues) => {
startTransition(async () => {
try {
if (invoice) {
await updateInvoice(invoice.id, data);
toast.success("Invoice updated");
router.push(`/invoices/${invoice.id}`);
} else {
const r = await createInvoice(data);
toast.success("Invoice created");
router.push(`/invoices/${r.invoice.id}`);
}
} catch {
toast.error("Failed to save invoice");
}
});
};
return (
<form onSubmit={handleSubmit(onSubmit as Parameters<typeof handleSubmit>[0])} className="space-y-8 p-6 max-w-3xl">
<div>
<h1 className="text-2xl font-bold text-white">{invoice ? "Edit Invoice" : "New Invoice"}</h1>
</div>
{/* Invoice details */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
<h2 className="font-semibold text-white">Invoice Details</h2>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Invoice Number *</Label>
<Input {...register("invoiceNumber")} className="bg-zinc-800 border-zinc-700 font-mono" />
{errors.invoiceNumber && <p className="text-xs text-red-400">{errors.invoiceNumber.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Controller control={control} name="status" render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{STATUSES.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
)} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Client *</Label>
<Controller control={control} name="clientId" render={({ field: f }) => (
<Select value={f.value} onValueChange={v => { f.onChange(v); setValue("jobId", ""); }}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue placeholder="Select client..." /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{clients.map(c => <SelectItem key={c.id} value={c.id}>{c.companyName}</SelectItem>)}
</SelectContent>
</Select>
)} />
{errors.clientId && <p className="text-xs text-red-400">{errors.clientId.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Job / Project *</Label>
<Controller control={control} name="jobId" render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue placeholder="Select job..." /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{filteredJobs.map(j => (
<SelectItem key={j.id} value={j.id}>{j.projectCode} — {j.projectTitle}</SelectItem>
))}
</SelectContent>
</Select>
)} />
{errors.jobId && <p className="text-xs text-red-400">{errors.jobId.message}</p>}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label>Issue Date *</Label>
<Input type="date" {...register("issueDate")} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Due Date</Label>
<Input type="date" {...register("dueDate")} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Payment Terms</Label>
<Input {...register("paymentTerms")} placeholder="e.g. Net 30" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
</div>
{/* Financials */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
<h2 className="font-semibold text-white">Financial</h2>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label>Currency</Label>
<Controller control={control} name="currency" render={({ field: f }) => (
<Select value={f.value} onValueChange={f.onChange}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{CURRENCIES.map(c => <SelectItem key={c.code} value={c.code}>{c.code} — {c.name}</SelectItem>)}
</SelectContent>
</Select>
)} />
</div>
<div className="space-y-1.5">
<Label>Subtotal</Label>
<Input type="number" min={0} step={0.01} {...register("subtotal", { valueAsNumber: true })} className="bg-zinc-800 border-zinc-700 font-mono" />
</div>
<div className="space-y-1.5">
<Label>Tax %</Label>
<Input type="number" min={0} max={100} step={0.5} {...register("taxPercent", { valueAsNumber: true })} className="bg-zinc-800 border-zinc-700" />
</div>
<input type="hidden" {...register("taxAmount", { valueAsNumber: true })} />
<input type="hidden" {...register("totalAmount", { valueAsNumber: true })} />
</div>
<div className="space-y-1.5 pt-2 border-t border-zinc-700">
{[
{ label: "Subtotal", value: Number(watch("subtotal") ?? 0) },
{ label: `Tax (${taxPercent}%)`, value: Number(watch("taxAmount") ?? 0) },
].map(row => (
<div key={row.label} className="flex justify-between text-sm">
<span className="text-zinc-400">{row.label}</span>
<span className="font-mono text-zinc-300">{formatCurrency(row.value, currency)}</span>
</div>
))}
<div className="flex justify-between font-bold pt-1 border-t border-zinc-700">
<span className="text-white">Total</span>
<span className="font-mono text-amber-400 text-lg">{formatCurrency(Number(watch("totalAmount") ?? 0), currency)}</span>
</div>
</div>
</div>
{/* Notes */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
<h2 className="font-semibold text-white">Notes & Details</h2>
<div className="space-y-1.5">
<Label>Notes (visible to client)</Label>
<Textarea {...register("notes")} rows={3} className="bg-zinc-800 border-zinc-700 resize-none" />
</div>
<div className="space-y-1.5">
<Label>Banking Details</Label>
<Textarea {...register("bankingDetails")} rows={4} placeholder="Bank name, account number, branch code..." className="bg-zinc-800 border-zinc-700 resize-none font-mono text-sm" />
</div>
<div className="space-y-1.5">
<Label>Internal Notes</Label>
<Textarea {...register("internalNotes")} rows={2} className="bg-zinc-800 border-zinc-700 resize-none" />
</div>
</div>
<div className="flex gap-3">
<Button type="submit" disabled={isPending} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{invoice ? "Update Invoice" : "Create Invoice"}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()} className="border-zinc-700 text-zinc-300">
Cancel
</Button>
</div>
</form>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { Plus, Search, FileText, ArrowUpRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { deleteInvoice } from "@/actions/invoices";
import { formatCurrency } from "@/lib/calculations";
import { toast } from "sonner";
import type { Prisma } from "@prisma/client";
type InvoiceWithRelations = Prisma.InvoiceGetPayload<{
include: {
client: { select: { companyName: true } };
job: { select: { projectTitle: true; projectCode: true } };
payments: true;
};
}>;
const STATUS_COLORS: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
VIEWED: "bg-purple-900/60 text-purple-300",
PARTIALLY_PAID: "bg-orange-900/60 text-orange-300",
PAID: "bg-green-900/60 text-green-300",
OVERDUE: "bg-red-900/60 text-red-300",
CANCELLED: "bg-zinc-800 text-zinc-500",
};
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SENT: "Sent", VIEWED: "Viewed",
PARTIALLY_PAID: "Partial", PAID: "Paid", OVERDUE: "Overdue", CANCELLED: "Cancelled",
};
interface Props {
initialInvoices: InvoiceWithRelations[];
}
export function InvoicesClient({ initialInvoices }: Props) {
const [invoices, setInvoices] = useState(initialInvoices);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [isPending, startTransition] = useTransition();
const filtered = invoices.filter(inv => {
const matchesSearch = search === "" ||
inv.invoiceNumber.toLowerCase().includes(search.toLowerCase()) ||
inv.client.companyName.toLowerCase().includes(search.toLowerCase()) ||
inv.job.projectCode.toLowerCase().includes(search.toLowerCase());
const matchesStatus = statusFilter === "ALL" || inv.status === statusFilter;
return matchesSearch && matchesStatus;
});
const totalInvoiced = filtered.reduce((s, i) => s + Number(i.totalAmount), 0);
const totalPaid = filtered.reduce((s, i) => s + Number(i.amountPaid), 0);
const totalDue = filtered.reduce((s, i) => s + Number(i.amountDue), 0);
const handleDelete = (id: string) => {
if (!confirm("Delete this invoice?")) return;
startTransition(async () => {
try {
await deleteInvoice(id);
setInvoices(prev => prev.filter(i => i.id !== id));
toast.success("Invoice deleted");
} catch {
toast.error("Failed to delete invoice");
}
});
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Invoices</h1>
<p className="text-sm text-zinc-400 mt-0.5">{invoices.length} invoice{invoices.length !== 1 ? "s" : ""}</p>
</div>
<Link href="/invoices/new">
<Button className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
<Plus className="h-4 w-4 mr-1.5" /> New Invoice
</Button>
</Link>
</div>
{/* Summary */}
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Total Invoiced", value: formatCurrency(totalInvoiced, "ZAR"), color: "text-white" },
{ label: "Total Paid", value: formatCurrency(totalPaid, "ZAR"), color: "text-green-400" },
{ label: "Outstanding", value: formatCurrency(totalDue, "ZAR"), color: "text-amber-400" },
].map(c => (
<div key={c.label} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{c.label}</p>
<p className={`text-xl font-bold font-mono ${c.color}`}>{c.value}</p>
</div>
))}
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search invoices..."
className="pl-9 bg-zinc-900 border-zinc-700"
/>
</div>
<Select value={statusFilter} onValueChange={v => setStatusFilter(v ?? "ALL")}>
<SelectTrigger className="w-44 bg-zinc-900 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="ALL">All Statuses</SelectItem>
{Object.entries(STATUS_LABELS).map(([v, l]) => (
<SelectItem key={v} value={v}>{l}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
{filtered.length === 0 ? (
<div className="text-center py-16 text-zinc-500">
<FileText className="h-8 w-8 mx-auto mb-3 opacity-40" />
<p>No invoices found</p>
</div>
) : (
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-zinc-900 border-b border-zinc-800">
{["Invoice #", "Client", "Project", "Issue Date", "Due Date", "Amount", "Paid", "Status", ""].map(h => (
<th key={h} className="text-left text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(inv => (
<tr key={inv.id} className="border-b border-zinc-800 hover:bg-zinc-900/50 transition-colors">
<td className="px-4 py-3">
<Link href={`/invoices/${inv.id}`} className="font-mono font-semibold text-amber-400 hover:text-amber-300 text-sm">
{inv.invoiceNumber}
</Link>
</td>
<td className="px-4 py-3 text-sm text-zinc-300">{inv.client.companyName}</td>
<td className="px-4 py-3 text-sm text-zinc-400">{inv.job.projectCode}</td>
<td className="px-4 py-3 text-sm text-zinc-400">{format(new Date(inv.issueDate), "MMM d, yyyy")}</td>
<td className="px-4 py-3 text-sm text-zinc-400">
{inv.dueDate ? format(new Date(inv.dueDate), "MMM d, yyyy") : "—"}
</td>
<td className="px-4 py-3 text-sm font-mono font-semibold text-white text-right">
{formatCurrency(Number(inv.totalAmount), "ZAR")}
</td>
<td className="px-4 py-3 text-sm font-mono text-green-400 text-right">
{formatCurrency(Number(inv.amountPaid), "ZAR")}
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-semibold ${STATUS_COLORS[inv.status] ?? ""}`}>
{STATUS_LABELS[inv.status] ?? inv.status}
</span>
</td>
<td className="px-4 py-3">
<Link href={`/invoices/${inv.id}`}>
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-400 hover:text-white">
<ArrowUpRight className="h-3.5 w-3.5" />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { toast } from "sonner";
import { ArrowLeft, Download, Copy, FileText, Settings, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { JobForm } from "./job-form";
import { ShotsEditor } from "@/components/shots/shots-editor";
import { AssetsEditor } from "@/components/assets/assets-editor";
import { SupervisionEditor } from "@/components/supervision/supervision-editor";
import { PrevisEditor } from "@/components/previs/previs-editor";
import { JobTotals } from "./job-totals";
import { PaymentScheduleEditor } from "@/components/payment-schedule/payment-schedule-editor";
import { duplicateJob, updateJobStatus } from "@/actions/jobs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { JOB_STATUSES } from "@/lib/constants";
import type { Prisma } from "@prisma/client";
type JobWithRelations = Prisma.JobGetPayload<{
include: {
client: true;
shots: { include: { lineItems: { include: { rateCard: true } } } };
assets: { include: { lineItems: { include: { rateCard: true } } } };
supervisions: { include: { lineItems: { include: { rateCard: true } } } };
previses: { include: { lineItems: { include: { rateCard: true } } } };
comments: { include: { user: true } };
history: true;
paymentSchedule: { include: { milestones: true } };
};
}>;
const statusColors: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
APPROVED: "bg-green-900/60 text-green-300",
REJECTED: "bg-red-900/60 text-red-300",
ARCHIVED: "bg-yellow-900/60 text-yellow-300",
};
export function JobDetailView({
job,
clients,
rateCards,
taskTemplates,
jobTotal,
}: {
job: JobWithRelations;
clients: { id: string; companyName: string }[];
rateCards: any[];
taskTemplates: any[];
jobTotal?: number;
}) {
const [activeStatus, setActiveStatus] = useState<string>(job.status);
const handleStatusChange = async (status: string) => {
try {
await updateJobStatus(job.id, status);
setActiveStatus(status);
toast.success(`Status updated to ${status}`);
} catch {
toast.error("Failed to update status");
}
};
const handleDuplicate = async () => {
try {
const result = await duplicateJob(job.id);
toast.success("Quote duplicated");
window.location.href = `/jobs/${result.job.id}`;
} catch {
toast.error("Failed to duplicate");
}
};
const handlePdfDownload = () => {
window.open(`/api/pdf/${job.id}`, "_blank");
};
return (
<div className="flex flex-col h-full">
{/* Top bar */}
<div className="border-b border-zinc-800 px-8 py-4 flex items-center gap-4 bg-zinc-950">
<Link href="/jobs" className="text-zinc-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold text-white truncate">{job.projectTitle}</h1>
<div className="text-sm text-zinc-500 flex items-center gap-2">
<span>{job.projectCode}</span>
<span>·</span>
<span>{job.client.companyName}</span>
<span>·</span>
<span>Issued {format(new Date(job.issueDate), "MMM d, yyyy")}</span>
</div>
</div>
{/* Status */}
<DropdownMenu>
<DropdownMenuTrigger className={`px-3 py-1.5 rounded-md text-xs font-semibold flex items-center gap-1.5 ${statusColors[activeStatus]}`}>
{JOB_STATUSES.find((s) => s.value === activeStatus)?.label}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-zinc-900 border-zinc-800">
{JOB_STATUSES.map((s) => (
<DropdownMenuItem key={s.value} onClick={() => handleStatusChange(s.value)}>
{s.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={handleDuplicate} className="border-zinc-700">
<Copy className="h-4 w-4 mr-1.5" /> Duplicate
</Button>
<Button
size="sm"
onClick={handlePdfDownload}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
<Download className="h-4 w-4 mr-1.5" /> PDF
</Button>
</div>
{/* Tabs */}
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="shots" className="h-full flex flex-col">
<div className="border-b border-zinc-800 px-8">
<TabsList className="bg-transparent border-0 h-auto p-0 gap-0">
{["shots", "assets", "supervision", "previs", "details", "totals", "payment"].map((tab) => (
<TabsTrigger
key={tab}
value={tab}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-amber-500 data-[state=active]:text-white data-[state=active]:bg-transparent text-zinc-400 capitalize px-4 py-3 text-sm"
>
{tab === "shots" ? `Shots (${job.shots.length})` :
tab === "assets" ? `Assets (${job.assets.length})` :
tab === "supervision" ? `Supervision (${(job as any).supervisions?.length ?? 0})` :
tab === "previs" ? `Previs (${(job as any).previses?.length ?? 0})` :
tab === "payment" ? "Payment Schedule" :
tab.charAt(0).toUpperCase() + tab.slice(1)}
</TabsTrigger>
))}
</TabsList>
</div>
<div className="flex-1 overflow-y-auto p-8">
<TabsContent value="shots" className="m-0">
<ShotsEditor job={job} rateCards={rateCards} taskTemplates={taskTemplates} />
</TabsContent>
<TabsContent value="assets" className="m-0">
<AssetsEditor job={job} rateCards={rateCards} />
</TabsContent>
<TabsContent value="supervision" className="m-0">
<SupervisionEditor job={job as any} rateCards={rateCards} />
</TabsContent>
<TabsContent value="previs" className="m-0">
<PrevisEditor job={job as any} rateCards={rateCards} />
</TabsContent>
<TabsContent value="details" className="m-0 max-w-3xl">
<JobForm clients={clients} initialData={{
projectTitle: job.projectTitle,
projectCode: job.projectCode,
showId: job.showId,
jobType: job.jobType as any,
clientId: job.clientId,
issueDate: format(new Date(job.issueDate), "yyyy-MM-dd"),
expiryDate: job.expiryDate ? format(new Date(job.expiryDate), "yyyy-MM-dd") : undefined,
currency: job.currency,
status: job.status as any,
description: job.description ?? undefined,
internalNotes: job.internalNotes ?? undefined,
markupPercent: Number(job.markupPercent),
taxPercent: Number(job.taxPercent),
discountPercent: Number(job.discountPercent),
productionFeePercent: Number((job as any).productionFeePercent ?? 0),
pullManagementFeePercent: Number((job as any).pullManagementFeePercent ?? 0),
termsAndConditions: job.termsAndConditions ?? undefined,
}} jobId={job.id} />
</TabsContent>
<TabsContent value="totals" className="m-0">
<JobTotals job={job} />
</TabsContent>
<TabsContent value="payment" className="m-0">
<PaymentScheduleEditor
jobId={job.id}
jobTotal={jobTotal ?? 0}
currency="ZAR"
startDate={job.startDate ? format(new Date(job.startDate), "yyyy-MM-dd") : null}
estimatedCompletionDate={job.estimatedCompletionDate ? format(new Date(job.estimatedCompletionDate), "yyyy-MM-dd") : null}
schedule={(job as any).paymentSchedule ?? null}
/>
</TabsContent>
</div>
</Tabs>
</div>
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { jobSchema, type JobFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { createJob, updateJob } from "@/actions/jobs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { CURRENCIES, JOB_STATUSES } from "@/lib/constants";
import { format } from "date-fns";
interface Client {
id: string;
companyName: string;
}
interface Props {
clients: Client[];
initialData?: Partial<JobFormValues>;
jobId?: string;
onSuccess?: () => void;
}
export function JobForm({ clients, initialData, jobId, onSuccess }: Props) {
const router = useRouter();
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
} = useForm<JobFormValues>({
resolver: zodResolver(jobSchema) as Resolver<JobFormValues>,
defaultValues: {
currency: "ZAR",
status: "DRAFT",
markupPercent: 0,
taxPercent: 0,
discountPercent: 0,
productionFeePercent: 0,
pullManagementFeePercent: 0,
issueDate: format(new Date(), "yyyy-MM-dd"),
...initialData,
},
});
const onSubmit = async (data: JobFormValues) => {
try {
if (jobId) {
await updateJob(jobId, data);
toast.success("Job updated");
onSuccess?.();
} else {
const result = await createJob(data);
toast.success("Quote created");
router.push(`/jobs/${result.job.id}`);
}
} catch (err: any) {
toast.error(err?.message ?? "Something went wrong");
}
};
return (
<form onSubmit={handleSubmit(onSubmit as Parameters<typeof handleSubmit>[0])} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div className="space-y-1.5">
<Label>Project Title *</Label>
<Input {...register("projectTitle")} className="bg-zinc-900 border-zinc-700" placeholder="e.g. Hero Sequence VFX" />
{errors.projectTitle && <p className="text-red-400 text-xs">{errors.projectTitle.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Project Code *</Label>
<Input {...register("projectCode")} className="bg-zinc-900 border-zinc-700" placeholder="e.g. VFX-2025-001" />
{errors.projectCode && <p className="text-red-400 text-xs">{errors.projectCode.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Show ID *</Label>
<Input
{...register("showId")}
className="bg-zinc-900 border-zinc-700 uppercase"
placeholder="e.g. STV"
maxLength={10}
onChange={e => {
e.target.value = e.target.value.toUpperCase();
register("showId").onChange(e);
}}
/>
<p className="text-xs text-zinc-500">Short code used in shot names (e.g. STV → STV_SQ010_0010)</p>
{errors.showId && <p className="text-red-400 text-xs">{errors.showId.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Job Type</Label>
<Select onValueChange={(v) => setValue("jobType", v as any)} defaultValue={initialData?.jobType ?? "STANDARD"}>
<SelectTrigger className="bg-zinc-900 border-zinc-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="STANDARD">Standard</SelectItem>
<SelectItem value="EPISODIC">Episodic</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Client *</Label>
<Select onValueChange={(v) => setValue("clientId", v ?? "")} defaultValue={initialData?.clientId ?? ""}>
<SelectTrigger className="bg-zinc-900 border-zinc-700">
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.companyName}</SelectItem>
))}
</SelectContent>
</Select>
{errors.clientId && <p className="text-red-400 text-xs">{errors.clientId.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Select onValueChange={(v) => setValue("status", v as any)} defaultValue={initialData?.status ?? "DRAFT"}>
<SelectTrigger className="bg-zinc-900 border-zinc-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{JOB_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Issue Date *</Label>
<Input {...register("issueDate")} type="date" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Expiry Date</Label>
<Input {...register("expiryDate")} type="date" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Currency</Label>
<Select onValueChange={(v) => setValue("currency", v ?? "ZAR")} defaultValue={initialData?.currency ?? "ZAR"}>
<SelectTrigger className="bg-zinc-900 border-zinc-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code}>{c.symbol} {c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Markup %</Label>
<Input {...register("markupPercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Tax / VAT %</Label>
<Input {...register("taxPercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Discount %</Label>
<Input {...register("discountPercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Production Fee %</Label>
<Input {...register("productionFeePercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" placeholder="0" />
<p className="text-xs text-zinc-500">% of total — shown as a line on quotes</p>
</div>
<div className="space-y-1.5">
<Label>Pull Management Fee %</Label>
<Input {...register("pullManagementFeePercent")} type="number" step="0.01" className="bg-zinc-900 border-zinc-700" placeholder="0" />
<p className="text-xs text-zinc-500">% of total — shown as a line on quotes</p>
</div>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Textarea {...register("description")} className="bg-zinc-900 border-zinc-700" rows={3} />
</div>
<div className="space-y-1.5">
<Label>Internal Notes</Label>
<Textarea {...register("internalNotes")} className="bg-zinc-900 border-zinc-700" rows={2} placeholder="Not included in PDF output" />
</div>
<div className="space-y-1.5">
<Label>Terms & Conditions</Label>
<Textarea {...register("termsAndConditions")} className="bg-zinc-900 border-zinc-700" rows={4} />
</div>
<Button
type="submit"
disabled={isSubmitting}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{jobId ? "Save Changes" : "Create Quote"}
</Button>
</form>
);
}
+286
View File
@@ -0,0 +1,286 @@
"use client";
import Decimal from "decimal.js";
import { calcLineItem, sumLineItems, applyJobAdjustments, formatCurrency } from "@/lib/calculations";
import type { Prisma } from "@prisma/client";
type JobWithRelations = Prisma.JobGetPayload<{
include: {
client: true;
shots: { include: { lineItems: { include: { rateCard: true } } } };
assets: { include: { lineItems: { include: { rateCard: true } } } };
supervisions: { include: { lineItems: { include: { rateCard: true } } } };
previses: { include: { lineItems: { include: { rateCard: true } } } };
comments: { include: { user: true } };
history: true;
};
}>;
export function JobTotals({ job }: { job: JobWithRelations }) {
const currency = "ZAR";
// Calculate shot totals
const shotSections = job.shots.map((shot) => {
const results = shot.lineItems.map((li) =>
calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
shot.complexityMultiplier.toString()
)
);
return { shot, totals: sumLineItems(results) };
});
// Calculate asset totals
const assetSections = job.assets.map((asset) => {
const results = asset.lineItems.map((li) =>
calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
asset.complexityMultiplier.toString()
)
);
return { asset, totals: sumLineItems(results) };
});
const allShotTotals = sumLineItems(shotSections.map((s) => s.totals));
const allAssetTotals = sumLineItems(assetSections.map((a) => a.totals));
// Calculate supervision totals
const supervisionSections = ((job as any).supervisions ?? []).map((sv: any) => {
const results = sv.lineItems.map((li: any) =>
calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
sv.complexityMultiplier.toString()
)
);
return { sv, totals: sumLineItems(results) };
});
const allSupervisionTotals = sumLineItems(supervisionSections.map((s: any) => s.totals));
// Calculate previs totals
const previsSections = ((job as any).previses ?? []).map((pv: any) => {
const results = pv.lineItems.map((li: any) =>
calcLineItem(
{
quantityHours: li.quantityHours.toString(),
dailyRate: li.dailyRate.toString(),
hoursPerDay: li.hoursPerDay.toString(),
numArtists: li.numArtists.toString(),
numDays: li.numDays.toString(),
manualHourlyRate: li.manualHourlyRate?.toString(),
manualSubtotal: li.manualSubtotal?.toString(),
markupPercent: li.markupPercent.toString(),
discountPercent: li.discountPercent.toString(),
taxPercent: li.taxPercent.toString(),
},
pv.complexityMultiplier.toString()
)
);
return { pv, totals: sumLineItems(results) };
});
const allPrevisTotal = sumLineItems(previsSections.map((s: any) => s.totals));
const lineSubtotal = allShotTotals.total.add(allAssetTotals.total).add(allSupervisionTotals.total).add(allPrevisTotal.total);
const jobAdj = applyJobAdjustments(
lineSubtotal,
job.markupPercent.toString(),
job.discountPercent.toString(),
job.taxPercent.toString()
);
const productionFeePct = new Decimal((job as any).productionFeePercent ?? 0);
const pullManagementFeePct = new Decimal((job as any).pullManagementFeePercent ?? 0);
const productionFeeAmount = jobAdj.grandTotal.mul(productionFeePct.div(100));
const pullManagementFeeAmount = jobAdj.grandTotal.mul(pullManagementFeePct.div(100));
const grandTotalWithFees = jobAdj.grandTotal.add(productionFeeAmount).add(pullManagementFeeAmount);
const fmt = (v: Decimal) => formatCurrency(v, currency);
return (
<div className="max-w-2xl space-y-8">
<div>
<h2 className="text-xl font-bold text-white mb-4">Cost Summary</h2>
{/* Shots */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-800/50">
<span className="font-semibold text-white text-sm">Shots</span>
</div>
<table className="w-full text-sm">
<tbody>
{shotSections.map(({ shot, totals }) => (
<tr key={shot.id} className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-300">{shot.shotCode}</td>
<td className="px-4 py-2 text-right font-mono text-white">{fmt(totals.total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="px-4 py-2 font-semibold text-white">Shots Subtotal</td>
<td className="px-4 py-2 text-right font-mono font-semibold text-amber-400">{fmt(allShotTotals.total)}</td>
</tr>
</tfoot>
</table>
</div>
{/* Assets */}
{assetSections.length > 0 && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-800/50">
<span className="font-semibold text-white text-sm">Assets</span>
</div>
<table className="w-full text-sm">
<tbody>
{assetSections.map(({ asset, totals }) => (
<tr key={asset.id} className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-300">{asset.assetName}</td>
<td className="px-4 py-2 text-right font-mono text-white">{fmt(totals.total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="px-4 py-2 font-semibold text-white">Assets Subtotal</td>
<td className="px-4 py-2 text-right font-mono font-semibold text-amber-400">{fmt(allAssetTotals.total)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Supervision */}
{supervisionSections.length > 0 && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-800/50">
<span className="font-semibold text-white text-sm">Supervision</span>
</div>
<table className="w-full text-sm">
<tbody>
{supervisionSections.map(({ sv, totals }: any) => (
<tr key={sv.id} className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-300">{sv.supervisionTitle}</td>
<td className="px-4 py-2 text-right font-mono text-white">{fmt(totals.total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="px-4 py-2 font-semibold text-white">Supervision Subtotal</td>
<td className="px-4 py-2 text-right font-mono font-semibold text-amber-400">{fmt(allSupervisionTotals.total)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Previs */}
{previsSections.length > 0 && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden mb-4">
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-800/50">
<span className="font-semibold text-white text-sm">Previs</span>
</div>
<table className="w-full text-sm">
<tbody>
{previsSections.map(({ pv, totals }: any) => (
<tr key={pv.id} className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-300">
{pv.sequenceName}
<span className="ml-2 text-xs text-zinc-500">{pv.sequenceType}</span>
</td>
<td className="px-4 py-2 text-right font-mono text-white">{fmt(totals.total)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-zinc-700">
<td className="px-4 py-2 font-semibold text-white">Previs Subtotal</td>
<td className="px-4 py-2 text-right font-mono font-semibold text-violet-400">{fmt(allPrevisTotal.total)}</td>
</tr>
</tfoot>
</table>
</div>
)}
{/* Grand total */}
<div className="rounded-xl border border-zinc-700 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<tbody>
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Line Items Total</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">{fmt(lineSubtotal)}</td>
</tr>
{Number(job.markupPercent) > 0 && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Markup ({Number(job.markupPercent)}%)</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">+{fmt(jobAdj.markupAmount)}</td>
</tr>
)}
{Number(job.discountPercent) > 0 && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Discount ({Number(job.discountPercent)}%)</td>
<td className="px-4 py-2 text-right font-mono text-red-400">-{fmt(jobAdj.discountAmount)}</td>
</tr>
)}
{Number(job.taxPercent) > 0 && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Tax / VAT ({Number(job.taxPercent)}%)</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">+{fmt(jobAdj.taxAmount)}</td>
</tr>
)}
{productionFeePct.gt(0) && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Production Fee ({productionFeePct.toNumber()}%)</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">+{fmt(productionFeeAmount)}</td>
</tr>
)}
{pullManagementFeePct.gt(0) && (
<tr className="border-b border-zinc-800/50">
<td className="px-4 py-2 text-zinc-400">Pull Management Fee ({pullManagementFeePct.toNumber()}%)</td>
<td className="px-4 py-2 text-right font-mono text-zinc-300">+{fmt(pullManagementFeeAmount)}</td>
</tr>
)}
<tr className="border-t border-amber-500/30 bg-amber-500/5">
<td className="px-4 py-3 font-bold text-white text-base">Grand Total</td>
<td className="px-4 py-3 text-right font-mono font-bold text-amber-400 text-lg">{fmt(grandTotalWithFees)}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { deleteJob, duplicateJob, updateJobStatus } from "@/actions/jobs";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MoreHorizontal, Search, Copy, Trash2, Eye, Briefcase } from "lucide-react";
import { format } from "date-fns";
import { JOB_STATUSES } from "@/lib/constants";
interface Job {
id: string;
projectTitle: string;
projectCode: string;
status: string;
currency: string;
issueDate: Date;
updatedAt: Date;
client: { companyName: string; contactPerson: string | null };
_count: { shots: number; assets: number };
}
const statusBadge: Record<string, string> = {
DRAFT: "bg-zinc-700 text-zinc-300",
SENT: "bg-blue-900/60 text-blue-300",
APPROVED: "bg-green-900/60 text-green-300",
REJECTED: "bg-red-900/60 text-red-300",
ARCHIVED: "bg-yellow-900/60 text-yellow-300",
};
export function JobsTable({ jobs }: { jobs: Job[] }) {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("ALL");
const [deleteId, setDeleteId] = useState<string | null>(null);
const filtered = jobs.filter((j) => {
const matchSearch =
j.projectTitle.toLowerCase().includes(search.toLowerCase()) ||
j.projectCode.toLowerCase().includes(search.toLowerCase()) ||
j.client.companyName.toLowerCase().includes(search.toLowerCase());
const matchStatus = statusFilter === "ALL" || j.status === statusFilter;
return matchSearch && matchStatus;
});
const handleDuplicate = async (id: string) => {
try {
const result = await duplicateJob(id);
toast.success("Quote duplicated");
} catch {
toast.error("Failed to duplicate");
}
};
const handleDelete = async () => {
if (!deleteId) return;
try {
await deleteJob(deleteId);
toast.success("Quote deleted");
} catch {
toast.error("Failed to delete");
}
setDeleteId(null);
};
return (
<>
<div className="flex gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
placeholder="Search jobs..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 bg-zinc-900 border-zinc-700"
/>
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v ?? "ALL")}>
<SelectTrigger className="w-40 bg-zinc-900 border-zinc-700">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="ALL">All Statuses</SelectItem>
{JOB_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{filtered.length === 0 ? (
<div className="text-center py-16 text-zinc-500">
<Briefcase className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p>No jobs found.</p>
</div>
) : (
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-400 text-xs uppercase tracking-wider">
<th className="px-4 py-3 text-left">Project</th>
<th className="px-4 py-3 text-left">Client</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Shots / Assets</th>
<th className="px-4 py-3 text-left">Date</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((job) => (
<tr key={job.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30 transition-colors">
<td className="px-4 py-3">
<Link href={`/jobs/${job.id}`} className="font-medium text-white hover:text-amber-400 transition-colors">
{job.projectTitle}
</Link>
<div className="text-xs text-zinc-500">{job.projectCode}</div>
</td>
<td className="px-4 py-3 text-zinc-300">{job.client.companyName}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusBadge[job.status]}`}>
{JOB_STATUSES.find((s) => s.value === job.status)?.label}
</span>
</td>
<td className="px-4 py-3 text-zinc-400">
{job._count.shots} shots · {job._count.assets} assets
</td>
<td className="px-4 py-3 text-zinc-500">
{format(new Date(job.issueDate), "MMM d, yyyy")}
</td>
<td className="px-4 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 w-7 rounded-lg hover:bg-zinc-700 transition-colors text-zinc-400 hover:text-zinc-200">
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-800">
<DropdownMenuItem onClick={() => window.location.href = `/jobs/${job.id}`}>
<Eye className="h-4 w-4 mr-2" /> View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicate(job.id)}>
<Copy className="h-4 w-4 mr-2" /> Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-zinc-800" />
<DropdownMenuItem
onClick={() => setDeleteId(job.id)}
className="text-red-400 focus:text-red-300"
>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-800">
<AlertDialogHeader>
<AlertDialogTitle>Delete quote?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the quote including all shots, assets and line items.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-500">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Users,
Briefcase,
CreditCard,
FileText,
Film,
ChevronLeft,
ChevronRight,
Settings,
} from "lucide-react";
import { useState } from "react";
const nav = [
{ href: "/", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/clients", icon: Users, label: "Clients" },
{ href: "/jobs", icon: Briefcase, label: "Jobs / Quotes" },
{ href: "/invoices", icon: FileText, label: "Invoices" },
{ href: "/rate-cards", icon: CreditCard, label: "Rate Cards" },
];
export function Sidebar() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
"flex flex-col bg-zinc-900 border-r border-zinc-800 transition-all duration-200",
collapsed ? "w-16" : "w-60"
)}
>
{/* Logo */}
<div className="flex items-center gap-3 px-4 py-5 border-b border-zinc-800">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center">
<Film className="h-4 w-4 text-black" />
</div>
{!collapsed && (
<span className="font-bold text-lg tracking-tight text-white">
x<span className="text-amber-500">Bid</span>
</span>
)}
</div>
{/* Nav */}
<nav className="flex-1 px-2 py-4 space-y-1">
{nav.map(({ href, icon: Icon, label }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
active
? "bg-amber-500/10 text-amber-400"
: "text-zinc-400 hover:text-white hover:bg-zinc-800"
)}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>{label}</span>}
</Link>
);
})}
</nav>
{/* Collapse toggle */}
<div className="px-2 pb-2">
{(() => {
const href = "/settings";
const active = pathname.startsWith(href);
return (
<Link
href={href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors",
active
? "bg-amber-500/10 text-amber-400"
: "text-zinc-400 hover:text-white hover:bg-zinc-800"
)}
>
<Settings className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Settings</span>}
</Link>
);
})()}
</div>
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center justify-center py-4 border-t border-zinc-800 text-zinc-500 hover:text-white transition-colors"
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</aside>
);
}
@@ -0,0 +1,358 @@
"use client";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import type { Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { lineItemSchema, type LineItemFormValues } from "@/lib/validations";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2, BookmarkPlus, Trash2 } from "lucide-react";
import { calcLineItem, formatCurrency } from "@/lib/calculations";
import { getTaskTemplates, createTaskTemplate, deleteTaskTemplate } from "@/actions/shots-assets";
import { toast } from "sonner";
interface TaskTemplate {
id: string;
name: string;
taskName: string;
artistRole: string | null;
rateCardId: 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;
}
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: LineItemFormValues) => Promise<void>;
rateCards: any[];
defaultTasks: string[];
initialData?: Partial<LineItemFormValues>;
}
export function LineItemDialog({ open, onOpenChange, onSubmit, rateCards, defaultTasks, initialData }: Props) {
const [templates, setTemplates] = useState<TaskTemplate[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
const [saveTemplateName, setSaveTemplateName] = useState("");
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
const [savingTemplate, setSavingTemplate] = useState(false);
// Load templates when dialog opens
useEffect(() => {
if (open) {
getTaskTemplates().then(setTemplates).catch(() => {});
}
}, [open]);
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<LineItemFormValues>({
resolver: zodResolver(lineItemSchema) as Resolver<LineItemFormValues>,
defaultValues: {
quantityHours: 0,
dailyRate: 0,
hoursPerDay: 8,
numArtists: 1,
numDays: 0,
markupPercent: 0,
discountPercent: 0,
taxPercent: 0,
...initialData,
},
});
const watched = watch();
const calc = calcLineItem({
quantityHours: watched.quantityHours ?? 0,
dailyRate: watched.dailyRate ?? 0,
hoursPerDay: watched.hoursPerDay ?? 8,
numArtists: watched.numArtists ?? 1,
numDays: watched.numDays ?? 0,
manualHourlyRate: watched.manualHourlyRate,
manualSubtotal: watched.manualSubtotal,
markupPercent: watched.markupPercent ?? 0,
discountPercent: watched.discountPercent ?? 0,
taxPercent: watched.taxPercent ?? 0,
});
const handleRateCardSelect = (id: string | null) => {
if (!id) return;
const rc = rateCards.find((r) => r.id === id);
if (rc) {
setValue("rateCardId", id);
setValue("dailyRate", Number(rc.defaultDailyRate));
setValue("hoursPerDay", Number(rc.hoursPerDay));
setValue("artistRole", rc.name);
}
};
const handleLoadTemplate = (templateId: string | null) => {
if (!templateId) return;
const t = templates.find((t) => t.id === templateId);
if (!t) return;
setSelectedTemplateId(templateId);
setValue("taskName", t.taskName);
if (t.artistRole) setValue("artistRole", t.artistRole);
if (t.rateCardId) setValue("rateCardId", t.rateCardId);
setValue("quantityHours", Number(t.quantityHours));
setValue("dailyRate", Number(t.dailyRate));
setValue("hoursPerDay", Number(t.hoursPerDay));
setValue("numArtists", Number(t.numArtists));
setValue("numDays", Number(t.numDays));
if (t.manualHourlyRate != null) setValue("manualHourlyRate", Number(t.manualHourlyRate));
if (t.manualSubtotal != null) setValue("manualSubtotal", Number(t.manualSubtotal));
setValue("markupPercent", Number(t.markupPercent));
setValue("discountPercent", Number(t.discountPercent));
setValue("taxPercent", Number(t.taxPercent));
if (t.notes) setValue("notes", t.notes);
};
const handleSaveTemplate = async () => {
if (!saveTemplateName.trim()) return;
setSavingTemplate(true);
try {
const current = watch();
const result = await createTaskTemplate({
name: saveTemplateName.trim(),
taskName: current.taskName ?? "",
artistRole: current.artistRole,
rateCardId: current.rateCardId,
quantityHours: current.quantityHours ?? 0,
dailyRate: current.dailyRate ?? 0,
hoursPerDay: current.hoursPerDay ?? 8,
numArtists: current.numArtists ?? 1,
numDays: current.numDays ?? 0,
manualHourlyRate: current.manualHourlyRate,
manualSubtotal: current.manualSubtotal,
markupPercent: current.markupPercent ?? 0,
discountPercent: current.discountPercent ?? 0,
taxPercent: current.taxPercent ?? 0,
notes: current.notes,
});
setTemplates((prev) => [...prev, result.template].sort((a, b) => a.name.localeCompare(b.name)));
setSaveTemplateName("");
setShowSaveTemplate(false);
toast.success("Template saved");
} catch {
toast.error("Failed to save template");
} finally {
setSavingTemplate(false);
}
};
const handleDeleteTemplate = async (id: string) => {
try {
await deleteTaskTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
if (selectedTemplateId === id) setSelectedTemplateId(null);
toast.success("Template deleted");
} catch {
toast.error("Failed to delete template");
}
};
const handleClose = () => { reset(); setShowSaveTemplate(false); setSaveTemplateName(""); setSelectedTemplateId(null); onOpenChange(false); };
const submit = async (data: LineItemFormValues) => { await onSubmit(data); reset(); };
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{initialData ? "Edit Task" : "Add Task"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Template picker */}
{templates.length > 0 && (
<div className="space-y-1.5 col-span-2">
<Label className="text-zinc-400">Load from Template</Label>
<div className="flex gap-2">
<Select value={selectedTemplateId ?? ""} onValueChange={handleLoadTemplate}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 flex-1">
<SelectValue placeholder="Select a template…" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700 max-h-60">
{templates.map((t) => (
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
{selectedTemplateId && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0 text-zinc-500 hover:text-red-400"
onClick={() => handleDeleteTemplate(selectedTemplateId)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
{/* Task name */}
<div className="space-y-1.5 col-span-2">
<Label>Task Name *</Label>
{defaultTasks.length > 0 ? (
<Select onValueChange={(v) => setValue("taskName", v ?? "")} defaultValue={initialData?.taskName}>
<SelectTrigger className="bg-zinc-800 border-zinc-700">
<SelectValue placeholder="Select or type task" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700 max-h-60">
{defaultTasks.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
) : null}
<Input {...register("taskName")} className="bg-zinc-800 border-zinc-700" placeholder="Custom task name" />
{errors.taskName && <p className="text-red-400 text-xs">{errors.taskName.message}</p>}
</div>
{/* Rate card */}
<div className="space-y-1.5 col-span-2">
<Label>Rate Card (optional)</Label>
<Select onValueChange={handleRateCardSelect} defaultValue={initialData?.rateCardId ?? undefined}>
<SelectTrigger className="bg-zinc-800 border-zinc-700">
<SelectValue placeholder="Apply rate card..." />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{rateCards.map((rc) => (
<SelectItem key={rc.id} value={rc.id}>
{rc.name} — {formatCurrency(Number(rc.defaultDailyRate), "ZAR")}/day
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Artist Role</Label>
<Input {...register("artistRole")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Senior Compositor" />
</div>
<div className="space-y-1.5">
<Label>Daily Rate</Label>
<Input {...register("dailyRate")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Hours / Day</Label>
<Input {...register("hoursPerDay")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Num. Artists</Label>
<Input {...register("numArtists")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Num. Days</Label>
<Input {...register("numDays")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Quantity Hours</Label>
<Input {...register("quantityHours")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Markup %</Label>
<Input {...register("markupPercent")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Discount %</Label>
<Input {...register("discountPercent")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Tax %</Label>
<Input {...register("taxPercent")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
{/* Live total preview */}
<div className="rounded-lg bg-zinc-800 p-3 grid grid-cols-3 gap-3 text-sm">
<div>
<div className="text-zinc-500 text-xs mb-0.5">Hourly Rate</div>
<div className="font-mono font-semibold text-white">{formatCurrency(calc.hourlyRate)}</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-0.5">Subtotal</div>
<div className="font-mono font-semibold text-white">{formatCurrency(calc.subtotal)}</div>
</div>
<div>
<div className="text-zinc-500 text-xs mb-0.5">Total</div>
<div className="font-mono font-semibold text-amber-400">{formatCurrency(calc.total)}</div>
</div>
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>Cancel</Button>
<Button
type="button"
variant="outline"
className="border-zinc-700 text-zinc-300"
onClick={() => setShowSaveTemplate(!showSaveTemplate)}
>
<BookmarkPlus className="h-4 w-4 mr-1.5" />
Save as Template
</Button>
<Button type="submit" disabled={isSubmitting} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData ? "Save Changes" : "Add Task"}
</Button>
</div>
{/* Save template inline form */}
{showSaveTemplate && (
<div className="flex gap-2 items-center border border-zinc-700 rounded-lg p-3 bg-zinc-800/50">
<Input
value={saveTemplateName}
onChange={(e) => setSaveTemplateName(e.target.value)}
placeholder="Template name…"
className="bg-zinc-800 border-zinc-700 flex-1"
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleSaveTemplate(); } }}
/>
<Button
type="button"
onClick={handleSaveTemplate}
disabled={savingTemplate || !saveTemplateName.trim()}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold shrink-0"
>
{savingTemplate ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
</Button>
</div>
)}
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,226 @@
"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={[]}
/>
)}
</>
);
}
@@ -0,0 +1,520 @@
"use client";
import { useState, useTransition, useMemo } from "react";
import { format } from "date-fns";
import { toast } from "sonner";
import { Plus, Trash2, FileText, Wand2, ChevronDown, ChevronUp, GripVertical, Check, AlertTriangle, Info, Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import {
addMilestone,
updateMilestone,
deleteMilestone,
generateScheduleFromTemplate,
createOrUpdateSchedule,
} from "@/actions/payment-schedule";
import { createInvoiceFromMilestone } from "@/actions/invoices";
import { formatCurrency } from "@/lib/calculations";
import type { Prisma } from "@prisma/client";
type Milestone = Prisma.PaymentMilestoneGetPayload<Record<string, never>>;
type Schedule = Prisma.PaymentScheduleGetPayload<{ include: { milestones: true } }> | null;
interface Props {
jobId: string;
jobTotal: number;
currency: string;
startDate?: string | null;
estimatedCompletionDate?: string | null;
schedule: Schedule;
}
const MILESTONE_STATUSES = [
{ value: "PENDING", label: "Pending", color: "bg-zinc-700 text-zinc-300" },
{ value: "INVOICED", label: "Invoiced", color: "bg-blue-900/60 text-blue-300" },
{ value: "PARTIALLY_PAID", label: "Partial", color: "bg-orange-900/60 text-orange-300" },
{ value: "PAID", label: "Paid", color: "bg-green-900/60 text-green-300" },
{ value: "OVERDUE", label: "Overdue", color: "bg-red-900/60 text-red-300" },
{ value: "CANCELLED", label: "Cancelled", color: "bg-zinc-800 text-zinc-500" },
];
const TEMPLATES = [
{ value: "STANDARD_FILM", label: "Standard Film / VFX", description: "50% deposit + monthly progress + 10% final" },
{ value: "SMALL_COMMERCIAL", label: "Small Commercial", description: "70% deposit + 30% on delivery" },
{ value: "EPISODIC", label: "Episodic / Longform", description: "25% deposit + monthly billing + 10% final" },
{ value: "CUSTOM", label: "Custom", description: "Manually create milestones" },
];
const emptyForm = {
title: "",
description: "",
dueDate: "",
percentOfTotal: 0,
fixedAmount: "" as string | number,
status: "PENDING" as string,
paymentReceivedDate: "",
notes: "",
sortOrder: 0,
};
export function PaymentScheduleEditor({ jobId, jobTotal, currency, startDate, estimatedCompletionDate, schedule: initialSchedule }: Props) {
const [schedule, setSchedule] = useState<Schedule>(initialSchedule);
const [milestones, setMilestones] = useState<Milestone[]>(initialSchedule?.milestones ?? []);
const [isPending, startTransition] = useTransition();
const [showGenerator, setShowGenerator] = useState(!initialSchedule);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingMilestone, setEditingMilestone] = useState<Milestone | null>(null);
const [form, setForm] = useState({ ...emptyForm });
// Generator state
const [genTemplate, setGenTemplate] = useState("STANDARD_FILM");
const [genDeposit, setGenDeposit] = useState(50);
const [genFinal, setGenFinal] = useState(10);
const [genMonths, setGenMonths] = useState(3);
const totalPercent = useMemo(() =>
milestones.reduce((s, m) => s + Number(m.percentOfTotal), 0),
[milestones]
);
const totalFixed = useMemo(() =>
milestones.filter(m => m.fixedAmount).reduce((s, m) => s + Number(m.fixedAmount ?? 0), 0),
[milestones]
);
const fmt = (v: number) => formatCurrency(v, currency);
const amountForMilestone = (m: Milestone) => {
if (m.fixedAmount) return Number(m.fixedAmount);
return (Number(m.percentOfTotal) / 100) * jobTotal;
};
const handleGenerate = () => {
startTransition(async () => {
try {
const result = await generateScheduleFromTemplate(jobId, {
template: genTemplate as any,
jobTotal,
startDate: startDate ?? undefined,
endDate: estimatedCompletionDate ?? undefined,
depositPercent: genDeposit,
finalPercent: genFinal,
monthlyCount: genMonths,
});
if (result.success && result.schedule) {
setSchedule(result.schedule as any);
setMilestones(result.schedule.milestones);
setShowGenerator(false);
toast.success("Payment schedule generated");
}
} catch {
toast.error("Failed to generate schedule");
}
});
};
const openAdd = () => {
setEditingMilestone(null);
setForm({ ...emptyForm });
setDialogOpen(true);
};
const openEdit = (m: Milestone) => {
setEditingMilestone(m);
setForm({
title: m.title,
description: m.description ?? "",
dueDate: m.dueDate ? format(new Date(m.dueDate), "yyyy-MM-dd") : "",
percentOfTotal: Number(m.percentOfTotal),
fixedAmount: m.fixedAmount ? Number(m.fixedAmount) : "",
status: m.status,
paymentReceivedDate: m.paymentReceivedDate ? format(new Date(m.paymentReceivedDate), "yyyy-MM-dd") : "",
notes: m.notes ?? "",
sortOrder: m.sortOrder,
});
setDialogOpen(true);
};
const handleSave = () => {
startTransition(async () => {
try {
let scheduleId = schedule?.id;
if (!scheduleId) {
const r = await createOrUpdateSchedule(jobId, "CUSTOM");
scheduleId = r.schedule.id;
setSchedule(r.schedule as any);
}
const payload = {
title: form.title,
description: form.description || undefined,
dueDate: form.dueDate || undefined,
percentOfTotal: Number(form.percentOfTotal) || 0,
fixedAmount: form.fixedAmount !== "" ? Number(form.fixedAmount) : undefined,
status: form.status as any,
paymentReceivedDate: form.paymentReceivedDate || undefined,
notes: form.notes || undefined,
sortOrder: form.sortOrder,
};
if (editingMilestone) {
const r = await updateMilestone(jobId, editingMilestone.id, payload);
setMilestones(prev => prev.map(m => m.id === editingMilestone.id ? r.milestone as any : m));
toast.success("Milestone updated");
} else {
const r = await addMilestone(jobId, scheduleId!, payload);
setMilestones(prev => [...prev, r.milestone as any]);
toast.success("Milestone added");
}
setDialogOpen(false);
} catch {
toast.error("Failed to save milestone");
}
});
};
const handleDelete = (milestoneId: string) => {
startTransition(async () => {
try {
await deleteMilestone(jobId, milestoneId);
setMilestones(prev => prev.filter(m => m.id !== milestoneId));
toast.success("Milestone deleted");
} catch {
toast.error("Failed to delete milestone");
}
});
};
const handleCreateInvoice = (milestoneId: string) => {
startTransition(async () => {
try {
const r = await createInvoiceFromMilestone(milestoneId, jobTotal);
toast.success(`Invoice ${r.invoice.invoiceNumber} created`);
window.location.href = `/invoices/${r.invoice.id}`;
} catch (e: any) {
toast.error(e.message ?? "Failed to create invoice");
}
});
};
const paidAmount = milestones
.filter(m => m.status === "PAID" || m.status === "PARTIALLY_PAID")
.reduce((s, m) => s + amountForMilestone(m), 0);
const outstandingAmount = milestones
.filter(m => !["PAID", "CANCELLED"].includes(m.status))
.reduce((s, m) => s + amountForMilestone(m), 0);
const overdueAmount = milestones
.filter(m => m.status === "OVERDUE")
.reduce((s, m) => s + amountForMilestone(m), 0);
// Scope-change awareness: are there already-invoiced milestones alongside pending ones?
const hasInvoicedMilestones = milestones.some(m => ["INVOICED", "PARTIALLY_PAID", "PAID"].includes(m.status));
const hasPendingMilestones = milestones.some(m => ["PENDING", "OVERDUE"].includes(m.status));
const showScopeWarning = hasInvoicedMilestones && hasPendingMilestones;
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: "Job Total", value: fmt(jobTotal), color: "text-white" },
{ label: "Paid", value: fmt(paidAmount), color: "text-green-400" },
{ label: "Outstanding", value: fmt(outstandingAmount), color: "text-amber-400" },
{ label: "Overdue", value: fmt(overdueAmount), color: "text-red-400" },
].map(c => (
<div key={c.label} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{c.label}</p>
<p className={`text-lg font-bold font-mono ${c.color}`}>{c.value}</p>
</div>
))}
</div>
{/* Total validation */}
{milestones.length > 0 && (
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
Math.abs(totalPercent - 100) < 0.01
? "bg-green-900/20 border border-green-800 text-green-400"
: "bg-amber-900/20 border border-amber-800 text-amber-400"
}`}>
{Math.abs(totalPercent - 100) < 0.01
? <Check className="h-4 w-4" />
: <AlertTriangle className="h-4 w-4" />}
<span>
Schedule total: <strong>{totalPercent.toFixed(2)}%</strong>
{Math.abs(totalPercent - 100) >= 0.01 && ` — ${(100 - totalPercent).toFixed(2)}% unallocated`}
</span>
</div>
)}
{/* Scope-change notice */}
{showScopeWarning && (
<div className="flex items-start gap-2.5 px-4 py-3 bg-blue-950/40 border border-blue-800/50 rounded-lg text-sm text-blue-300">
<Info className="h-4 w-4 mt-0.5 shrink-0 text-blue-400" />
<span>
Some milestones have already been invoiced. If shots or assets have been added since,{" "}
<strong>uninvoiced milestone amounts will automatically recalculate</strong> to cover the
remaining balance when you generate their invoices — previously sent invoices are never
modified.
</span>
</div>
)}
{/* Generator panel */}
{showGenerator ? (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-white flex items-center gap-2"><Wand2 className="h-4 w-4 text-amber-500" /> Schedule Generator</h3>
<p className="text-sm text-zinc-400 mt-0.5">Choose a template to auto-generate payment milestones</p>
</div>
{schedule && (
<Button variant="ghost" size="sm" onClick={() => setShowGenerator(false)} className="text-zinc-400">
Cancel
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
{TEMPLATES.map(t => (
<button
key={t.value}
onClick={() => setGenTemplate(t.value)}
className={`text-left p-4 rounded-lg border transition-all ${
genTemplate === t.value
? "border-amber-500 bg-amber-500/10"
: "border-zinc-700 bg-zinc-800 hover:border-zinc-600"
}`}
>
<p className="font-medium text-white text-sm">{t.label}</p>
<p className="text-xs text-zinc-400 mt-0.5">{t.description}</p>
</button>
))}
</div>
{genTemplate !== "CUSTOM" && (
<div className="grid grid-cols-3 gap-4 pt-2">
<div className="space-y-1.5">
<Label className="text-xs">Deposit %</Label>
<Input
type="number" min={0} max={100} value={genDeposit}
onChange={e => setGenDeposit(Number(e.target.value))}
className="bg-zinc-800 border-zinc-700"
/>
</div>
{genTemplate !== "SMALL_COMMERCIAL" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Final %</Label>
<Input
type="number" min={0} max={100} value={genFinal}
onChange={e => setGenFinal(Number(e.target.value))}
className="bg-zinc-800 border-zinc-700"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Monthly Payments</Label>
<Input
type="number" min={1} max={36} value={genMonths}
onChange={e => setGenMonths(Number(e.target.value))}
className="bg-zinc-800 border-zinc-700"
/>
</div>
</>
)}
</div>
)}
<div className="flex gap-2 pt-2">
{genTemplate !== "CUSTOM" ? (
<Button onClick={handleGenerate} disabled={isPending} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
<Wand2 className="h-4 w-4 mr-1.5" /> Generate Schedule
</Button>
) : (
<Button onClick={() => { startTransition(async () => { await createOrUpdateSchedule(jobId, "CUSTOM"); setShowGenerator(false); }); }} disabled={isPending}>
Start Custom Schedule
</Button>
)}
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setShowGenerator(true)} className="border-zinc-700 text-zinc-300">
<Wand2 className="h-4 w-4 mr-1.5" /> Regenerate from Template
</Button>
<a href={`/api/pdf/payment-schedule/${jobId}`} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300">
<Printer className="h-4 w-4 mr-1.5" /> PDF
</Button>
</a>
</div>
<Button size="sm" onClick={openAdd} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
<Plus className="h-4 w-4 mr-1.5" /> Add Milestone
</Button>
</div>
)}
{/* Milestone table */}
{milestones.length > 0 && (
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full">
<thead>
<tr className="bg-zinc-900 border-b border-zinc-800">
<th className="text-left text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">Milestone</th>
<th className="text-left text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">Due Date</th>
<th className="text-right text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">%</th>
<th className="text-right text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">Amount</th>
<th className="text-left text-xs font-semibold text-zinc-500 uppercase tracking-wider px-4 py-3">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{milestones.map((m) => {
const statusCfg = MILESTONE_STATUSES.find(s => s.value === m.status);
const amount = amountForMilestone(m);
const isPaid = m.status === "PAID";
const hasInvoice = false; // will be populated once invoice linked
return (
<tr key={m.id} className="border-b border-zinc-800 hover:bg-zinc-900/50 transition-colors">
<td className="px-4 py-3">
<p className="font-medium text-white text-sm">{m.title}</p>
{m.description && <p className="text-xs text-zinc-500 mt-0.5">{m.description}</p>}
</td>
<td className="px-4 py-3 text-sm text-zinc-400">
{m.dueDate ? format(new Date(m.dueDate), "MMM d, yyyy") : "—"}
</td>
<td className="px-4 py-3 text-right font-mono text-sm text-zinc-300">
{Number(m.percentOfTotal).toFixed(1)}%
</td>
<td className="px-4 py-3 text-right font-mono text-sm font-semibold text-white">
{fmt(amount)}{showScopeWarning && ["PENDING", "OVERDUE"].includes(m.status) ? <span className="text-blue-400 ml-0.5">*</span> : null}
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-semibold ${statusCfg?.color ?? ""}`}>
{statusCfg?.label ?? m.status}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 justify-end">
{!isPaid && m.status !== "INVOICED" && m.status !== "CANCELLED" && (
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-zinc-400 hover:text-blue-400"
title="Create Invoice"
onClick={() => handleCreateInvoice(m.id)}
disabled={isPending}
>
<FileText className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-zinc-400 hover:text-white"
onClick={() => openEdit(m)}
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon"
className="h-7 w-7 text-zinc-400 hover:text-red-400"
onClick={() => handleDelete(m.id)}
disabled={isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="bg-zinc-900">
<td colSpan={2} className="px-4 py-3 text-sm font-semibold text-zinc-400">Total</td>
<td className={`px-4 py-3 text-right font-mono font-bold text-sm ${Math.abs(totalPercent - 100) < 0.01 ? "text-green-400" : "text-amber-400"}`}>
{totalPercent.toFixed(1)}%
</td>
<td className="px-4 py-3 text-right font-mono font-bold text-white">
{fmt(totalFixed > 0 ? totalFixed : (totalPercent / 100) * jobTotal)}
</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
{showScopeWarning && (
<p className="px-4 py-2 text-xs text-blue-400/70 border-t border-zinc-800">
* Displayed amount is based on current project scope. Actual invoice amount will be recalculated at generation time to cover the remaining balance.
</p>
)}
</div>
)}
{/* Milestone dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader>
<DialogTitle>{editingMilestone ? "Edit Milestone" : "Add Milestone"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label>Title *</Label>
<Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Deposit, Month 1 Progress..." />
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Due Date</Label>
<Input type="date" value={form.dueDate} onChange={e => setForm(f => ({ ...f, dueDate: e.target.value }))} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Select value={form.status} onValueChange={v => setForm(f => ({ ...f, status: v ?? "PENDING" }))}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{MILESTONE_STATUSES.map(s => <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>% of Total</Label>
<Input type="number" min={0} max={100} step={0.5} value={form.percentOfTotal} onChange={e => setForm(f => ({ ...f, percentOfTotal: Number(e.target.value) }))} className="bg-zinc-800 border-zinc-700" />
<p className="text-xs text-zinc-500">= {fmt((Number(form.percentOfTotal) / 100) * jobTotal)}</p>
</div>
<div className="space-y-1.5">
<Label>Fixed Amount Override</Label>
<Input type="number" min={0} step={100} value={form.fixedAmount as number} onChange={e => setForm(f => ({ ...f, fixedAmount: e.target.value === "" ? "" : Number(e.target.value) }))} className="bg-zinc-800 border-zinc-700" placeholder="Optional" />
<p className="text-xs text-zinc-500">Overrides % if set</p>
</div>
</div>
{(form.status === "PAID" || form.status === "PARTIALLY_PAID") && (
<div className="space-y-1.5">
<Label>Payment Received Date</Label>
<Input type="date" value={form.paymentReceivedDate} onChange={e => setForm(f => ({ ...f, paymentReceivedDate: e.target.value }))} className="bg-zinc-800 border-zinc-700" />
</div>
)}
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} className="bg-zinc-800 border-zinc-700 resize-none" rows={2} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)} className="border-zinc-700">Cancel</Button>
<Button onClick={handleSave} disabled={isPending || !form.title} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{editingMilestone ? "Update" : "Add Milestone"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+249
View File
@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { GripVertical, ChevronDown, ChevronRight, Trash2, Pencil, Plus, Film, Clock, Hash, RefreshCw } from "lucide-react";
import { updatePrevis, deleteLineItem, createLineItem } from "@/actions/shots-assets";
import { toast } from "sonner";
import { LineItemsTable } from "@/components/line-items/line-items-table";
import { PrevisDialog } from "./previs-dialog";
import { LineItemDialog } from "@/components/line-items/line-item-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
import { PREVIS_LINE_ITEM_FLAT } from "@/lib/constants";
interface Previs {
id: string;
sequenceName: string;
previsCode: string | null;
sequenceType: string;
description: string | null;
duration: string | null;
frameRange: string | null;
revisionCount: number;
deliveryStage: string;
complexityMultiplier: any;
notes: string | null;
lineItems: any[];
}
interface Props {
previs: Previs;
jobId: string;
currency: string;
rateCards: any[];
onDelete: (id: string) => void;
onUpdate: (previs: any) => void;
}
const STAGE_COLORS: Record<string, string> = {
"Blocking": "bg-zinc-700 text-zinc-300",
"First Pass": "bg-blue-900/40 text-blue-300",
"Client Review": "bg-amber-900/40 text-amber-300",
"Final Previs": "bg-violet-900/40 text-violet-300",
"Approved": "bg-green-900/40 text-green-300",
};
export function PrevisCard({ previs, jobId, currency, rateCards, onDelete, onUpdate }: Props) {
const [expanded, setExpanded] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [addLineOpen, setAddLineOpen] = useState(false);
const [lineItems, setLineItems] = useState(previs.lineItems);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: previs.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const totals = sumLineItems(
lineItems.map((li) =>
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",
}, previs.complexityMultiplier?.toString() ?? "1")
)
);
const handleEdit = async (data: any) => {
try {
const result = await updatePrevis(previs.id, jobId, data);
onUpdate(result.previs);
setEditOpen(false);
toast.success("Previs updated");
} catch {
toast.error("Failed to update previs");
}
};
const handleAddLineItem = async (data: any) => {
try {
const result = await createLineItem(previs.id, "previs", jobId, data);
setLineItems((prev) => [...prev, result.lineItem]);
setAddLineOpen(false);
toast.success("Line item added");
} catch {
toast.error("Failed to add line item");
}
};
const handleDeleteLineItem = async (id: string) => {
try {
await deleteLineItem(id, jobId);
setLineItems((prev) => prev.filter((li) => li.id !== id));
toast.success("Line item removed");
} catch {
toast.error("Failed to delete line item");
}
};
const handleLineItemUpdate = (updated: any) => {
setLineItems((prev) => prev.map((li) => (li.id === updated.id ? updated : li)));
};
const stageColor = STAGE_COLORS[previs.deliveryStage] ?? "bg-zinc-700 text-zinc-300";
return (
<div ref={setNodeRef} style={style} className="border border-zinc-800 rounded-xl overflow-hidden bg-zinc-900">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-900">
<button {...attributes} {...listeners} className="text-zinc-600 hover:text-zinc-400 cursor-grab">
<GripVertical className="h-4 w-4" />
</button>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-white truncate">{previs.sequenceName}</span>
{previs.previsCode && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-500 font-mono">
<Hash className="h-3 w-3" />{previs.previsCode}
</span>
)}
<span className="inline-flex items-center gap-0.5 text-xs text-violet-400">
<Film className="h-3 w-3" />{previs.sequenceType}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded-full ${stageColor}`}>
{previs.deliveryStage}
</span>
{previs.duration && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-500">
<Clock className="h-3 w-3" />{previs.duration}
</span>
)}
{previs.frameRange && (
<span className="text-xs text-zinc-500 font-mono">{previs.frameRange}</span>
)}
{previs.revisionCount > 0 && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-400">
<RefreshCw className="h-3 w-3" />{previs.revisionCount} rev{previs.revisionCount !== 1 ? "s" : ""}
</span>
)}
</div>
{previs.description && (
<p className="text-xs text-zinc-500 truncate mt-0.5">{previs.description}</p>
)}
</div>
</button>
<span className="text-sm font-mono font-semibold text-violet-400 shrink-0">
{formatCurrency(totals.total, currency)}
</span>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-zinc-400 hover:text-white"
onClick={() => setEditOpen(true)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-zinc-600 hover:text-red-400"
onClick={() => onDelete(previs.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Expanded content */}
{expanded && (
<div className="border-t border-zinc-800">
{/* Line items */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Line Items ({lineItems.length})
</span>
<Button
size="sm"
variant="ghost"
onClick={() => setAddLineOpen(true)}
className="h-7 text-xs text-violet-400 hover:text-violet-300"
>
<Plus className="h-3.5 w-3.5 mr-1" /> Add
</Button>
</div>
{lineItems.length > 0 ? (
<LineItemsTable
lineItems={lineItems}
complexityMultiplier={previs.complexityMultiplier?.toString() ?? "1"}
currency={currency}
parentId={previs.id}
parentType="previs"
jobId={jobId}
rateCards={rateCards}
onDelete={handleDeleteLineItem}
onUpdate={handleLineItemUpdate}
/>
) : (
<p className="text-xs text-zinc-600 py-2">No line items yet.</p>
)}
</div>
</div>
)}
<PrevisDialog
open={editOpen}
onOpenChange={setEditOpen}
onSubmit={handleEdit}
initialData={{
sequenceName: previs.sequenceName,
previsCode: previs.previsCode ?? undefined,
sequenceType: previs.sequenceType,
description: previs.description ?? undefined,
duration: previs.duration ?? undefined,
frameRange: previs.frameRange ?? undefined,
revisionCount: previs.revisionCount,
deliveryStage: previs.deliveryStage,
complexityMultiplier: Number(previs.complexityMultiplier),
notes: previs.notes ?? undefined,
}}
/>
<LineItemDialog
open={addLineOpen}
onOpenChange={setAddLineOpen}
onSubmit={handleAddLineItem}
rateCards={rateCards}
defaultTasks={PREVIS_LINE_ITEM_FLAT as unknown as string[]}
/>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { previsSchema, type PrevisFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { PREVIS_SEQUENCE_TYPES, PREVIS_DELIVERY_STAGES } from "@/lib/constants";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: PrevisFormValues) => Promise<void>;
initialData?: Partial<PrevisFormValues>;
}
export function PrevisDialog({ open, onOpenChange, onSubmit, initialData }: Props) {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<PrevisFormValues>({
resolver: zodResolver(previsSchema) as Resolver<PrevisFormValues>,
defaultValues: {
sequenceType: "Previs",
deliveryStage: "Blocking",
revisionCount: 0,
complexityMultiplier: 1,
...initialData,
},
});
const handleClose = () => { reset(); onOpenChange(false); };
const submit = async (data: PrevisFormValues) => { await onSubmit(data); reset(); };
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-white">
{initialData?.sequenceName ? "Edit Previs Sequence" : "Add Previs Sequence"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
{/* Sequence name + code */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Sequence Name *</Label>
<Input
{...register("sequenceName")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. Highway Chase"
/>
{errors.sequenceName && <p className="text-red-400 text-xs">{errors.sequenceName.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Previs Code</Label>
<Input
{...register("previsCode")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. PV_SEQ_010"
/>
</div>
</div>
{/* Sequence type + delivery stage */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Sequence Type</Label>
<select
{...register("sequenceType")}
className="w-full h-9 rounded-md bg-zinc-800 border border-zinc-700 text-white text-sm px-3"
>
{PREVIS_SEQUENCE_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label>Delivery Stage</Label>
<select
{...register("deliveryStage")}
className="w-full h-9 rounded-md bg-zinc-800 border border-zinc-700 text-white text-sm px-3"
>
{PREVIS_DELIVERY_STAGES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
{/* Duration + frame range + revisions + complexity */}
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1.5">
<Label>Duration</Label>
<Input
{...register("duration")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. 2:30"
/>
</div>
<div className="space-y-1.5">
<Label>Frame Range</Label>
<Input
{...register("frameRange")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. 1001-1250"
/>
</div>
<div className="space-y-1.5">
<Label>Revisions</Label>
<Input
{...register("revisionCount")}
type="number"
min="0"
className="bg-zinc-800 border-zinc-700"
/>
</div>
<div className="space-y-1.5">
<Label>Complexity ×</Label>
<Input
{...register("complexityMultiplier")}
type="number"
step="0.1"
min="0.1"
max="10"
className="bg-zinc-800 border-zinc-700"
/>
</div>
</div>
{/* Description + Notes */}
<div className="space-y-1.5">
<Label>Description</Label>
<Input
{...register("description")}
className="bg-zinc-800 border-zinc-700"
placeholder="Brief overview of the sequence"
/>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-violet-600 hover:bg-violet-500 text-white font-semibold"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData?.sequenceName ? "Save Changes" : "Add Previs"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { createPrevis, deletePrevis, reorderPrevises } from "@/actions/shots-assets";
import { PrevisCard } from "./previs-card";
import { PrevisDialog } from "./previs-dialog";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
interface Props {
job: {
id: string;
previses: any[];
currency?: string;
[key: string]: any;
};
rateCards: any[];
}
export function PrevisEditor({ job, rateCards }: Props) {
const [previews, setPreviews] = useState(job.previses ?? []);
const [dialogOpen, setDialogOpen] = useState(false);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = previews.findIndex((p) => p.id === active.id);
const newIndex = previews.findIndex((p) => p.id === over.id);
const reordered = arrayMove(previews, oldIndex, newIndex);
setPreviews(reordered);
await reorderPrevises(job.id, reordered.map((p) => p.id));
};
const handleCreate = async (data: any) => {
try {
const result = await createPrevis(job.id, data);
setPreviews((prev) => [...prev, result.previs]);
setDialogOpen(false);
toast.success("Previs sequence added");
} catch {
toast.error("Failed to add previs");
}
};
const handleDelete = async (previsId: string) => {
try {
await deletePrevis(previsId, job.id);
setPreviews((prev) => prev.filter((p) => p.id !== previsId));
toast.success("Previs deleted");
} catch {
toast.error("Failed to delete previs");
}
};
const handleUpdate = (updated: any) => {
setPreviews((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-white">Previs ({previews.length})</h2>
<p className="text-xs text-zinc-500 mt-0.5">Storyboard animation, techvis, stuntvis, pitchvis, virtual camera blocking</p>
</div>
<Button
onClick={() => setDialogOpen(true)}
size="sm"
className="bg-violet-600 hover:bg-violet-500 text-white font-semibold"
>
<Plus className="h-4 w-4 mr-1" /> Add Previs
</Button>
</div>
{previews.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-dashed border-zinc-700 text-zinc-500">
<p className="mb-1">No previs sequences yet.</p>
<p className="text-sm">Add previs, techvis, stuntvis, pitchvis or postvis sequences.</p>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={previews.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{previews.map((previs) => (
<PrevisCard
key={previs.id}
previs={previs}
jobId={job.id}
currency={job.currency ?? "ZAR"}
rateCards={rateCards}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<PrevisDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={handleCreate}
/>
</div>
);
}
@@ -0,0 +1,264 @@
"use client";
import { useState } from "react";
import { useForm, useFormContext } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { rateCardSchema, type RateCardFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { createRateCard, updateRateCard, deleteRateCard } from "@/actions/rate-cards";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { Plus, Pencil, Trash2, CreditCard, Loader2 } from "lucide-react";
import { CURRENCIES, RATE_CARD_ROLES } from "@/lib/constants";
import { formatCurrency } from "@/lib/calculations";
interface RateCard {
id: string;
name: string;
department: string | null;
defaultDailyRate: any;
hoursPerDay: any;
currency: string;
notes: string | null;
isActive: boolean;
}
function RateCardForm({
initialData,
rateCardId,
onSuccess,
onCancel,
}: {
initialData?: Partial<RateCardFormValues>;
rateCardId?: string;
onSuccess: (rc: RateCard) => void;
onCancel: () => void;
}) {
const { register, handleSubmit, setValue, formState: { errors, isSubmitting } } = useForm<RateCardFormValues>({
resolver: zodResolver(rateCardSchema) as Resolver<RateCardFormValues>,
defaultValues: { currency: "ZAR", hoursPerDay: 8, isActive: true, ...initialData },
});
const onSubmit = async (data: RateCardFormValues) => {
try {
if (rateCardId) {
const result = await updateRateCard(rateCardId, data);
onSuccess(result.rateCard as any);
toast.success("Rate card updated");
} else {
const result = await createRateCard(data);
onSuccess(result.rateCard as any);
toast.success("Rate card created");
}
} catch {
toast.error("Something went wrong");
}
};
return (
<form onSubmit={handleSubmit(onSubmit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label>Name *</Label>
<div className="flex gap-2">
<Input {...register("name")} className="bg-zinc-800 border-zinc-700 flex-1" placeholder="e.g. Senior Compositor" />
<Select onValueChange={(v) => {
const role = RATE_CARD_ROLES.find(r => r.name === v);
if (role) { setValue("name", role.name); setValue("department", role.department); }
}}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 w-44">
<SelectValue placeholder="Preset role" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{RATE_CARD_ROLES.map((r) => <SelectItem key={r.name} value={r.name}>{r.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{errors.name && <p className="text-red-400 text-xs">{errors.name.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Department</Label>
<Input {...register("department")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Compositing" />
</div>
<div className="space-y-1.5">
<Label>Currency</Label>
<Select onValueChange={(v) => setValue("currency", v ?? "ZAR")} defaultValue={initialData?.currency ?? "ZAR"}>
<SelectTrigger className="bg-zinc-800 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.symbol} {c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Default Daily Rate *</Label>
<Input {...register("defaultDailyRate")} type="number" step="0.01" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Hours / Day</Label>
<Input {...register("hoursPerDay")} type="number" step="0.5" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end">
<Button type="button" variant="outline" className="border-zinc-700" onClick={onCancel}>Cancel</Button>
<Button type="submit" disabled={isSubmitting} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{rateCardId ? "Save Changes" : "Create"}
</Button>
</div>
</form>
);
}
export function RateCardsClient({ initialRateCards }: { initialRateCards: RateCard[] }) {
const [rateCards, setRateCards] = useState(initialRateCards);
const [dialogOpen, setDialogOpen] = useState(false);
const [editCard, setEditCard] = useState<RateCard | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const handleCreated = (rc: RateCard) => { setRateCards((p) => [...p, rc]); setDialogOpen(false); };
const handleUpdated = (rc: RateCard) => { setRateCards((p) => p.map((c) => c.id === rc.id ? rc : c)); setEditCard(null); };
const handleDelete = async () => {
if (!deleteId) return;
try { await deleteRateCard(deleteId); setRateCards((p) => p.filter((c) => c.id !== deleteId)); toast.success("Deleted"); }
catch { toast.error("Failed to delete"); }
setDeleteId(null);
};
const grouped = rateCards.reduce<Record<string, RateCard[]>>((acc, rc) => {
const dept = rc.department ?? "Other";
acc[dept] = acc[dept] ? [...acc[dept], rc] : [rc];
return acc;
}, {});
return (
<>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Rate Cards</h1>
<p className="text-zinc-400 mt-1">{rateCards.length} rate card{rateCards.length !== 1 ? "s" : ""}</p>
</div>
<Button onClick={() => setDialogOpen(true)} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
<Plus className="h-4 w-4 mr-2" /> New Rate Card
</Button>
</div>
{rateCards.length === 0 ? (
<div className="text-center py-16 text-zinc-500">
<CreditCard className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p>No rate cards yet.</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(grouped).map(([dept, cards]) => (
<div key={dept}>
<h2 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider mb-2">{dept}</h2>
<div className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-zinc-500 text-xs uppercase tracking-wider">
<th className="px-4 py-3 text-left">Name</th>
<th className="px-4 py-3 text-right">Daily Rate</th>
<th className="px-4 py-3 text-right">Hrs/Day</th>
<th className="px-4 py-3 text-right">Hourly Rate</th>
<th className="px-4 py-3 text-center">Active</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{cards.map((rc) => {
const hourly = Number(rc.hoursPerDay) > 0
? Number(rc.defaultDailyRate) / Number(rc.hoursPerDay)
: 0;
return (
<tr key={rc.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30 transition-colors">
<td className="px-4 py-3 font-medium text-white">{rc.name}</td>
<td className="px-4 py-3 text-right font-mono text-zinc-300">
{formatCurrency(Number(rc.defaultDailyRate), rc.currency)}
</td>
<td className="px-4 py-3 text-right text-zinc-400">{Number(rc.hoursPerDay)}</td>
<td className="px-4 py-3 text-right font-mono text-zinc-400">
{formatCurrency(hourly, rc.currency)}
</td>
<td className="px-4 py-3 text-center">
<span className={`text-xs ${rc.isActive ? "text-green-400" : "text-zinc-600"}`}>
{rc.isActive ? "✓" : "—"}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setEditCard(rc)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => setDeleteId(rc.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader><DialogTitle>New Rate Card</DialogTitle></DialogHeader>
<RateCardForm onSuccess={handleCreated} onCancel={() => setDialogOpen(false)} />
</DialogContent>
</Dialog>
{/* Edit dialog */}
{editCard && (
<Dialog open={!!editCard} onOpenChange={() => setEditCard(null)}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader><DialogTitle>Edit Rate Card</DialogTitle></DialogHeader>
<RateCardForm
rateCardId={editCard.id}
initialData={{
name: editCard.name,
department: editCard.department ?? undefined,
defaultDailyRate: Number(editCard.defaultDailyRate),
hoursPerDay: Number(editCard.hoursPerDay),
currency: editCard.currency,
notes: editCard.notes ?? undefined,
isActive: editCard.isActive,
}}
onSuccess={handleUpdated}
onCancel={() => setEditCard(null)}
/>
</DialogContent>
</Dialog>
)}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-800">
<AlertDialogHeader>
<AlertDialogTitle>Delete rate card?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700">Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-500">Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useForm } from "react-hook-form";
import type { Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { studioSettingsSchema, type StudioSettingsFormValues } from "@/lib/validations";
import { updateStudioSettings } from "@/actions/settings";
import { CURRENCIES } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import type { StudioSettings } from "@prisma/client";
interface Props {
initialData: StudioSettings;
}
export function SettingsForm({ initialData }: Props) {
const {
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<StudioSettingsFormValues>({
resolver: zodResolver(studioSettingsSchema) as Resolver<StudioSettingsFormValues>,
defaultValues: {
studioName: initialData.studioName,
studioEmail: initialData.studioEmail ?? "",
studioPhone: initialData.studioPhone ?? "",
studioAddress: initialData.studioAddress ?? "",
logoUrl: initialData.logoUrl ?? "",
vatNumber: initialData.vatNumber ?? "",
companyRegNumber: initialData.companyRegNumber ?? "",
invoiceHeader: initialData.invoiceHeader ?? "",
invoiceFooter: initialData.invoiceFooter ?? "",
defaultTermsAndConditions: initialData.defaultTermsAndConditions ?? "",
defaultCurrency: initialData.defaultCurrency,
defaultMarkupPercent: Number(initialData.defaultMarkupPercent),
defaultDiscountPercent: Number(initialData.defaultDiscountPercent),
defaultTaxPercent: Number(initialData.defaultTaxPercent),
},
});
const onSubmit = async (data: StudioSettingsFormValues) => {
const result = await updateStudioSettings(data);
if ("success" in result && result.success) {
toast.success("Settings saved");
} else {
toast.error("Failed to save settings");
}
};
return (
<form
onSubmit={handleSubmit(onSubmit as Parameters<typeof handleSubmit>[0])}
className="space-y-8"
>
{/* Studio Identity */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Studio Identity</h2>
<p className="text-sm text-zinc-400">Your company details shown on quotes and invoices.</p>
</div>
<Separator className="bg-zinc-800" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Studio Name *</Label>
<Input {...register("studioName")} className="bg-zinc-900 border-zinc-700" />
{errors.studioName && <p className="text-red-400 text-xs">{errors.studioName.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Email</Label>
<Input {...register("studioEmail")} type="email" className="bg-zinc-900 border-zinc-700" />
{errors.studioEmail && <p className="text-red-400 text-xs">{errors.studioEmail.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Phone</Label>
<Input {...register("studioPhone")} className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>VAT Number</Label>
<Input {...register("vatNumber")} className="bg-zinc-900 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Company Reg. Number</Label>
<Input {...register("companyRegNumber")} className="bg-zinc-900 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Physical / Billing Address</Label>
<Textarea
{...register("studioAddress")}
className="bg-zinc-900 border-zinc-700 resize-none"
rows={3}
placeholder={"123 Studio Lane\nCape Town, 8001\nSouth Africa"}
/>
</div>
</section>
{/* Branding */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Branding</h2>
<p className="text-sm text-zinc-400">Logo and quote/invoice header/footer content.</p>
</div>
<Separator className="bg-zinc-800" />
<div className="space-y-1.5">
<Label>Logo URL</Label>
<Input
{...register("logoUrl")}
className="bg-zinc-900 border-zinc-700"
placeholder="https://yourstudio.com/logo.png"
/>
<p className="text-xs text-zinc-500">Paste a publicly accessible image URL. Appears at the top of every PDF quote.</p>
</div>
<div className="space-y-1.5">
<Label>Quote / Invoice Header</Label>
<Textarea
{...register("invoiceHeader")}
className="bg-zinc-900 border-zinc-700 resize-none font-mono text-sm"
rows={4}
placeholder="Shown at the top of every quote, below the logo. Plain text or basic HTML."
/>
</div>
<div className="space-y-1.5">
<Label>Quote / Invoice Footer</Label>
<Textarea
{...register("invoiceFooter")}
className="bg-zinc-900 border-zinc-700 resize-none font-mono text-sm"
rows={4}
placeholder="Shown at the bottom of every quote. Plain text or basic HTML."
/>
</div>
</section>
{/* Default Terms */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Default Terms & Conditions</h2>
<p className="text-sm text-zinc-400">Pre-filled on every new quote. Can be overridden per-job.</p>
</div>
<Separator className="bg-zinc-800" />
<div className="space-y-1.5">
<Textarea
{...register("defaultTermsAndConditions")}
className="bg-zinc-900 border-zinc-700 resize-none text-sm"
rows={6}
/>
</div>
</section>
{/* Financial Defaults */}
<section className="space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Financial Defaults</h2>
<p className="text-sm text-zinc-400">Applied to new jobs and rate cards. Can be overridden per-job.</p>
</div>
<Separator className="bg-zinc-800" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-1.5">
<Label>Default Currency</Label>
<Select
onValueChange={(v) => setValue("defaultCurrency", v ?? "ZAR")}
defaultValue={initialData.defaultCurrency}
>
<SelectTrigger className="bg-zinc-900 border-zinc-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
{CURRENCIES.map((c) => (
<SelectItem key={c.code} value={c.code}>{c.symbol} {c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Default Markup %</Label>
<Input
{...register("defaultMarkupPercent")}
type="number"
min={0}
max={100}
step={0.5}
className="bg-zinc-900 border-zinc-700"
/>
</div>
<div className="space-y-1.5">
<Label>Default Discount %</Label>
<Input
{...register("defaultDiscountPercent")}
type="number"
min={0}
max={100}
step={0.5}
className="bg-zinc-900 border-zinc-700"
/>
</div>
<div className="space-y-1.5">
<Label>Default Tax (VAT) %</Label>
<Input
{...register("defaultTaxPercent")}
type="number"
min={0}
max={100}
step={0.5}
className="bg-zinc-900 border-zinc-700"
/>
</div>
</div>
</section>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting} className="min-w-32">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save Settings"}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,282 @@
"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Loader2, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { importShotsFromCsv, type CsvShotRow } from "@/actions/shots-assets";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
jobId: string;
onImported: (shots: any[]) => void;
}
// ── Simple CSV parser (handles quoted fields) ─────────────────────────────────
function parseCsv(text: string): string[][] {
const rows: string[][] = [];
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
for (const line of lines) {
if (!line.trim()) continue;
const cols: string[] = [];
let cur = "";
let inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuote && line[i + 1] === '"') {
cur += '"';
i++;
} else {
inQuote = !inQuote;
}
} else if (ch === "," && !inQuote) {
cols.push(cur.trim());
cur = "";
} else {
cur += ch;
}
}
cols.push(cur.trim());
rows.push(cols);
}
return rows;
}
const SHOT_CODE_ALIASES = ["shotcode", "shot_code", "shot", "name", "code", "id"];
const DURATION_ALIASES = ["duration", "durationseconds", "duration_seconds", "seconds", "secs", "sec", "dur"];
const DESCRIPTION_ALIASES = ["description", "desc", "notes", "note", "details"];
function detectColumnIndex(header: string, aliases: string[]): number {
return aliases.indexOf(header.toLowerCase().replace(/\s+/g, ""));
}
interface ParsedRow {
shotCode: string;
durationSeconds?: number;
description?: string;
}
function csvToRows(text: string): { rows: ParsedRow[]; error?: string } {
const parsed = parseCsv(text);
if (parsed.length === 0) return { rows: [], error: "No data found" };
// Detect if first row is a header
const firstRow = parsed[0];
const lcFirst = firstRow.map((c) => c.toLowerCase().replace(/\s+/g, ""));
const hasHeader =
lcFirst.some((c) => SHOT_CODE_ALIASES.includes(c)) ||
lcFirst.some((c) => DURATION_ALIASES.includes(c)) ||
lcFirst.some((c) => DESCRIPTION_ALIASES.includes(c));
let codeIdx = -1;
let durIdx = -1;
let descIdx = -1;
let dataRows: string[][];
if (hasHeader) {
lcFirst.forEach((col, i) => {
if (codeIdx === -1 && SHOT_CODE_ALIASES.includes(col)) codeIdx = i;
if (durIdx === -1 && DURATION_ALIASES.includes(col)) durIdx = i;
if (descIdx === -1 && DESCRIPTION_ALIASES.includes(col)) descIdx = i;
});
dataRows = parsed.slice(1);
} else {
// No header: assume col 0 = code, col 1 = duration, col 2 = description
codeIdx = 0;
durIdx = parsed[0].length > 1 ? 1 : -1;
descIdx = parsed[0].length > 2 ? 2 : -1;
dataRows = parsed;
}
if (codeIdx === -1) return { rows: [], error: "Could not find a shot code/name column" };
const rows: ParsedRow[] = [];
for (const cols of dataRows) {
const shotCode = cols[codeIdx]?.trim();
if (!shotCode) continue;
const rawDur = durIdx >= 0 ? cols[durIdx]?.trim() : undefined;
const durationSeconds = rawDur ? Number(rawDur) : undefined;
const description = descIdx >= 0 ? cols[descIdx]?.trim() || undefined : undefined;
rows.push({ shotCode, durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : undefined, description });
}
return { rows };
}
export function ImportCsvShotsDialog({ open, onOpenChange, jobId, onImported }: Props) {
const fileRef = useRef<HTMLInputElement>(null);
const [rows, setRows] = useState<ParsedRow[]>([]);
const [parseError, setParseError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
const reset = () => {
setRows([]);
setParseError(null);
setFileName(null);
if (fileRef.current) fileRef.current.value = "";
};
const handleFile = (file: File) => {
setFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
const { rows: parsed, error } = csvToRows(text);
if (error) {
setParseError(error);
setRows([]);
} else {
setParseError(null);
setRows(parsed);
}
};
reader.readAsText(file);
};
const handleImport = async () => {
if (!rows.length) return;
setImporting(true);
try {
const csvRows: CsvShotRow[] = rows.map((r) => ({
shotCode: r.shotCode,
durationSeconds: r.durationSeconds,
description: r.description,
}));
const result = await importShotsFromCsv(jobId, csvRows);
const created = result.shots.length;
const skipped = result.skipped ?? 0;
if (created > 0) {
onImported(result.shots);
toast.success(`Imported ${created} shot${created !== 1 ? "s" : ""}${skipped ? ` (${skipped} duplicate${skipped !== 1 ? "s" : ""} skipped)` : ""}`);
} else {
toast.info(`All ${skipped} shot${skipped !== 1 ? "s" : ""} already exist — nothing imported`);
}
reset();
onOpenChange(false);
} catch (err: any) {
toast.error(err.message ?? "Import failed");
} finally {
setImporting(false);
}
};
const handleClose = () => {
reset();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-700 text-white max-w-2xl">
<DialogHeader>
<DialogTitle>Import Shots from CSV</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
Upload a CSV file with columns for shot code/name, duration (seconds), and description. The header row is optional.
</p>
{/* Expected format hint */}
<div className="rounded-md bg-zinc-800 border border-zinc-700 px-3 py-2 text-xs font-mono text-zinc-400">
<span className="text-zinc-500">Expected columns (any order, headers optional):</span>
<br />
<span className="text-amber-400">shotCode</span>
{", "}
<span className="text-amber-400">duration</span>
{", "}
<span className="text-amber-400">description</span>
<br />
<span className="text-zinc-600">e.g. &nbsp;ABC_SC_0010, 120, Wide establishing shot</span>
</div>
{/* File drop area */}
{!fileName ? (
<label
className="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-zinc-700 py-10 cursor-pointer hover:border-amber-500 transition-colors"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}}
>
<Upload className="h-6 w-6 text-zinc-500" />
<span className="text-sm text-zinc-400">
Drop a <span className="text-white">.csv</span> file here, or{" "}
<span className="text-amber-400 underline">browse</span>
</span>
<input
ref={fileRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
}}
/>
</label>
) : (
<div className="flex items-center justify-between rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2">
<span className="text-sm text-zinc-300 truncate">{fileName}</span>
<button onClick={reset} className="ml-2 text-zinc-500 hover:text-white transition-colors shrink-0">
<X className="h-4 w-4" />
</button>
</div>
)}
{parseError && (
<p className="text-sm text-red-400">{parseError}</p>
)}
{/* Preview table */}
{rows.length > 0 && (
<div className="rounded-lg border border-zinc-700 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-zinc-800 border-b border-zinc-700">
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Preview — {rows.length} shot{rows.length !== 1 ? "s" : ""}
</span>
</div>
<div className="max-h-52 overflow-y-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-700 text-xs text-zinc-500 uppercase tracking-wider">
<th className="px-3 py-2 text-left">Shot Code</th>
<th className="px-3 py-2 text-left">Duration (s)</th>
<th className="px-3 py-2 text-left">Description</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-b border-zinc-800 hover:bg-zinc-800/50">
<td className="px-3 py-2 font-mono text-amber-400">{r.shotCode}</td>
<td className="px-3 py-2 text-zinc-300">{r.durationSeconds ?? <span className="text-zinc-600">—</span>}</td>
<td className="px-3 py-2 text-zinc-400 max-w-xs truncate">{r.description || <span className="text-zinc-600">—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" onClick={handleClose} className="border-zinc-700 text-zinc-300">
Cancel
</Button>
<Button
onClick={handleImport}
disabled={rows.length === 0 || importing}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{importing ? <><Loader2 className="h-4 w-4 mr-1 animate-spin" /> Importing…</> : `Import ${rows.length > 0 ? rows.length : ""} Shot${rows.length !== 1 ? "s" : ""}`}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,297 @@
"use client";
import { useState, useMemo } from "react";
import { toast } from "sonner";
import { Loader2, Search, CheckSquare, Square, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { fetchExternalShots, importShotsFromApi, type ExternalShot } from "@/actions/shots-assets";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
jobId: string;
onImported: (shots: any[]) => void;
}
const STATUS_COLORS: Record<string, string> = {
COMPLETE: "bg-green-900/60 text-green-300",
IN_REVIEW: "bg-blue-900/60 text-blue-300",
IN_PROGRESS: "bg-amber-900/60 text-amber-300",
REVISIONS: "bg-red-900/60 text-red-300",
WAITING: "bg-zinc-700 text-zinc-300",
};
export function ImportShotsDialog({ open, onOpenChange, jobId, onImported }: Props) {
const [projectId, setProjectId] = useState("");
const [fetching, setFetching] = useState(false);
const [importing, setImporting] = useState(false);
const [allShots, setAllShots] = useState<ExternalShot[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [collapsedEpisodes, setCollapsedEpisodes] = useState<Set<string>>(new Set());
const [hasFetched, setHasFetched] = useState(false);
// Group shots by episode (or "All" for non-episodic)
const grouped = useMemo(() => {
const map = new Map<string, ExternalShot[]>();
allShots.forEach((s) => {
const key = s.episode ?? "Ungrouped";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
});
const result: { label: string; shots: ExternalShot[] }[] = [];
// Sort episode keys
Array.from(map.keys())
.sort((a, b) => {
if (a === "Ungrouped") return 1;
if (b === "Ungrouped") return -1;
return a.localeCompare(b, undefined, { numeric: true });
})
.forEach((k) => result.push({ label: k, shots: map.get(k)! }));
return result;
}, [allShots]);
const isEpisodic = grouped.length > 1 || (grouped.length === 1 && grouped[0].label !== "Ungrouped");
const handleFetch = async () => {
if (!projectId.trim()) {
toast.error("Enter a project ID");
return;
}
setFetching(true);
setAllShots([]);
setSelected(new Set());
setHasFetched(false);
try {
const data = await fetchExternalShots(projectId.trim());
setAllShots(data.shots);
setHasFetched(true);
if (data.shots.length === 0) toast.info("No shots found for this project");
} catch (err: any) {
toast.error(err.message ?? "Failed to fetch shots");
} finally {
setFetching(false);
}
};
const toggleShot = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleEpisode = (label: string) => {
const episodeShots = grouped.find((g) => g.label === label)?.shots ?? [];
const ids = episodeShots.map((s) => s.id);
const allSelected = ids.every((id) => selected.has(id));
setSelected((prev) => {
const next = new Set(prev);
if (allSelected) {
ids.forEach((id) => next.delete(id));
} else {
ids.forEach((id) => next.add(id));
}
return next;
});
};
const toggleAll = () => {
if (selected.size === allShots.length) {
setSelected(new Set());
} else {
setSelected(new Set(allShots.map((s) => s.id)));
}
};
const toggleCollapse = (label: string) => {
setCollapsedEpisodes((prev) => {
const next = new Set(prev);
next.has(label) ? next.delete(label) : next.add(label);
return next;
});
};
const handleImport = async () => {
const toImport = allShots.filter((s) => selected.has(s.id));
if (!toImport.length) {
toast.error("Select at least one shot to import");
return;
}
setImporting(true);
try {
const result = await importShotsFromApi(jobId, toImport);
onImported(result.shots);
const skippedMsg = result.skipped ? ` (${result.skipped} already existed, skipped)` : "";
toast.success(`${result.shots.length} shot${result.shots.length !== 1 ? "s" : ""} imported${skippedMsg}`);
onOpenChange(false);
handleReset();
} catch (err: any) {
toast.error(err.message ?? "Import failed");
} finally {
setImporting(false);
}
};
const handleReset = () => {
setProjectId("");
setAllShots([]);
setSelected(new Set());
setCollapsedEpisodes(new Set());
setHasFetched(false);
};
const handleClose = (v: boolean) => {
if (!v) handleReset();
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-4xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Import Shots from Project</DialogTitle>
</DialogHeader>
{/* Project ID input */}
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<Label className="text-xs text-zinc-400">Project ID</Label>
<Input
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleFetch()}
placeholder="e.g. cmp6l5mzq0001ua0gz07bk72f"
className="bg-zinc-800 border-zinc-700 font-mono text-sm"
disabled={fetching}
/>
</div>
<div className="flex items-end">
<Button
onClick={handleFetch}
disabled={fetching || !projectId.trim()}
className="bg-zinc-700 hover:bg-zinc-600 text-white"
>
{fetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1.5">{fetching ? "Fetching…" : "Fetch Shots"}</span>
</Button>
</div>
</div>
{/* Results */}
{hasFetched && allShots.length > 0 && (
<>
{/* Toolbar */}
<div className="flex items-center justify-between border-t border-zinc-800 pt-3">
<button
onClick={toggleAll}
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-white transition-colors"
>
{selected.size === allShots.length ? (
<CheckSquare className="h-4 w-4 text-amber-400" />
) : (
<Square className="h-4 w-4" />
)}
{selected.size === allShots.length ? "Deselect all" : "Select all"}
<span className="text-zinc-600">({allShots.length} shots)</span>
</button>
{selected.size > 0 && (
<span className="text-xs text-amber-400 font-medium">
{selected.size} selected
</span>
)}
</div>
{/* Shot list */}
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
{grouped.map(({ label, shots }) => {
const episodeSelected = shots.every((s) => selected.has(s.id));
const episodePartial = !episodeSelected && shots.some((s) => selected.has(s.id));
const collapsed = collapsedEpisodes.has(label);
return (
<div key={label}>
{/* Episode header */}
<div className="flex items-center gap-2 mb-2">
<button
onClick={() => toggleCollapse(label)}
className="text-zinc-500 hover:text-zinc-300 transition-colors"
>
{collapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
<button
onClick={() => toggleEpisode(label)}
className="flex items-center gap-2 group"
>
{episodeSelected ? (
<CheckSquare className="h-4 w-4 text-amber-400 shrink-0" />
) : (
<Square className={`h-4 w-4 shrink-0 ${episodePartial ? "text-amber-600" : "text-zinc-600 group-hover:text-zinc-400"}`} />
)}
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 group-hover:text-zinc-200 transition-colors">
{isEpisodic ? `Episode ${label}` : label}
</span>
</button>
<span className="text-xs text-zinc-600">· {shots.length} shot{shots.length !== 1 ? "s" : ""}</span>
</div>
{/* Shots */}
{!collapsed && (
<div className="space-y-1 pl-6">
{shots.map((shot) => (
<button
key={shot.id}
onClick={() => toggleShot(shot.id)}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800/60 transition-colors text-left"
>
{selected.has(shot.id) ? (
<CheckSquare className="h-4 w-4 text-amber-400 shrink-0" />
) : (
<Square className="h-4 w-4 text-zinc-600 shrink-0" />
)}
<span className="font-mono text-sm text-amber-400 shrink-0 min-w-50">
{shot.shotCode}
</span>
<span className="text-sm text-zinc-400 truncate flex-1">
{shot.description ?? "—"}
</span>
<Badge className={`text-[10px] px-1.5 py-0 shrink-0 ${STATUS_COLORS[shot.status] ?? "bg-zinc-700 text-zinc-300"}`}>
{shot.status.replace("_", " ")}
</Badge>
</button>
))}
</div>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="border-t border-zinc-800 pt-3 flex items-center justify-between">
<span className="text-xs text-zinc-500">
{selected.size} of {allShots.length} shots selected
</span>
<Button
onClick={handleImport}
disabled={importing || selected.size === 0}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{importing && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Import {selected.size > 0 ? selected.size : ""} Shot{selected.size !== 1 ? "s" : ""}
</Button>
</div>
</>
)}
{hasFetched && allShots.length === 0 && (
<p className="text-center py-8 text-zinc-500 text-sm">No shots found for this project ID.</p>
)}
</DialogContent>
</Dialog>
);
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import {
GripVertical,
ChevronDown,
ChevronRight,
Trash2,
Copy,
Pencil,
Plus,
Loader2,
} from "lucide-react";
import { updateShot, deleteLineItem, createLineItem } from "@/actions/shots-assets";
import { toast } from "sonner";
import { LineItemsTable } from "@/components/line-items/line-items-table";
import { ShotDialog } from "./shot-dialog";
import { LineItemDialog } from "@/components/line-items/line-item-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
import Decimal from "decimal.js";
interface Shot {
id: string;
shotCode: string;
shotNumber: number;
scene: string;
episode: string | null;
shotGroup: string | null;
description: string | null;
durationSeconds: number | null;
complexityMultiplier: any;
notes: string | null;
lineItems: any[];
}
interface Props {
shot: Shot;
jobId: string;
currency: string;
rateCards: any[];
taskTemplates: any[];
showId: string;
jobType: "STANDARD" | "EPISODIC";
existingGroups?: string[];
onDelete: (id: string) => void;
onDuplicate: (id: string) => void;
onUpdate: (shot: any) => void;
}
export function ShotCard({ shot, jobId, currency, rateCards, taskTemplates, showId, jobType, existingGroups = [], onDelete, onDuplicate, onUpdate }: Props) {
const [expanded, setExpanded] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [addLineOpen, setAddLineOpen] = useState(false);
const [lineItems, setLineItems] = useState(shot.lineItems);
const [applyingTemplate, setApplyingTemplate] = useState<string | null>(null);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: shot.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const totals = sumLineItems(
lineItems.map((li) =>
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",
},
shot.complexityMultiplier?.toString() ?? "1"
)
)
);
const handleEdit = async (data: any) => {
try {
const result = await updateShot(shot.id, jobId, data);
onUpdate(result.shot);
setEditOpen(false);
toast.success("Shot updated");
} catch {
toast.error("Failed to update shot");
}
};
const handleAddLineItem = async (data: any) => {
try {
const result = await createLineItem(shot.id, "shot", jobId, data);
setLineItems((prev) => [...prev, result.lineItem]);
setAddLineOpen(false);
toast.success("Line item added");
} catch {
toast.error("Failed to add line item");
}
};
const handleDeleteLineItem = async (id: string) => {
try {
await deleteLineItem(id, jobId);
setLineItems((prev) => prev.filter((li) => li.id !== id));
toast.success("Line item removed");
} catch {
toast.error("Failed to delete line item");
}
};
const handleLineItemUpdate = (updated: any) => {
setLineItems((prev) => prev.map((li) => (li.id === updated.id ? updated : li)));
};
const handleApplyTemplate = async (template: any) => {
setApplyingTemplate(template.id);
try {
const result = await createLineItem(shot.id, "shot", jobId, {
taskName: template.taskName,
artistRole: template.artistRole ?? undefined,
rateCardId: template.rateCardId ?? undefined,
quantityHours: Number(template.quantityHours),
dailyRate: Number(template.dailyRate),
hoursPerDay: Number(template.hoursPerDay),
numArtists: Number(template.numArtists),
numDays: Number(template.numDays),
manualHourlyRate: template.manualHourlyRate != null ? Number(template.manualHourlyRate) : undefined,
manualSubtotal: template.manualSubtotal != null ? Number(template.manualSubtotal) : undefined,
markupPercent: Number(template.markupPercent),
discountPercent: Number(template.discountPercent),
taxPercent: Number(template.taxPercent),
notes: template.notes ?? undefined,
});
setLineItems((prev) => [...prev, result.lineItem]);
toast.success(`"${template.name}" applied`);
} catch {
toast.error("Failed to apply template");
} finally {
setApplyingTemplate(null);
}
};
return (
<div ref={setNodeRef} style={style} className="rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3">
<button {...attributes} {...listeners} className="text-zinc-600 hover:text-zinc-400 cursor-grab active:cursor-grabbing">
<GripVertical className="h-4 w-4" />
</button>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 flex-1 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-zinc-400" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-400" />
)}
<span className="font-semibold text-white">{shot.shotCode}</span>
{(shot.episode || shot.shotGroup) && (
<span className="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300">
{shot.episode ?? shot.shotGroup}
</span>
)}
{shot.description && (
<span className="text-zinc-500 text-sm truncate hidden sm:block">{shot.description}</span>
)}
<span className="ml-auto text-xs text-zinc-500">
{lineItems.length} tasks
</span>
</button>
<span className="font-mono text-sm text-amber-400 ml-4">
{formatCurrency(totals.total, currency)}
</span>
<div className="flex gap-1 ml-2">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setEditOpen(true)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDuplicate(shot.id)}>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-400 hover:text-red-300" onClick={() => onDelete(shot.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Expanded */}
{expanded && (
<div className="border-t border-zinc-800">
{shot.description || shot.notes || Number(shot.complexityMultiplier) !== 1 ? (
<div className="px-4 py-3 bg-zinc-800/30 text-sm text-zinc-400 flex gap-6">
{shot.description && <span>{shot.description}</span>}
{Number(shot.complexityMultiplier) !== 1 && (
<span className="text-amber-400">×{Number(shot.complexityMultiplier).toFixed(2)} complexity</span>
)}
{shot.durationSeconds && <span>{shot.durationSeconds}s</span>}
</div>
) : null}
<LineItemsTable
lineItems={lineItems}
currency={currency}
rateCards={rateCards}
complexityMultiplier={shot.complexityMultiplier?.toString() ?? "1"}
parentId={shot.id}
parentType="shot"
jobId={jobId}
onDelete={handleDeleteLineItem}
onUpdate={handleLineItemUpdate}
/>
<div className="px-4 py-3 border-t border-zinc-800 flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-sm"
onClick={() => setAddLineOpen(true)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Add Task
</Button>
{taskTemplates.map((template) => (
<Button
key={template.id}
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-400 hover:text-white hover:border-amber-500/50 text-xs h-7 px-2.5"
disabled={applyingTemplate === template.id}
onClick={() => handleApplyTemplate(template)}
>
{applyingTemplate === template.id ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Plus className="h-3 w-3 mr-1 text-amber-500/70" />
)}
{template.name}
</Button>
))}
</div>
</div>
)}
<ShotDialog
open={editOpen}
onOpenChange={setEditOpen}
onSubmit={handleEdit}
initialData={{
shotCode: shot.shotCode,
scene: shot.scene,
episode: shot.episode ?? undefined,
shotGroup: shot.shotGroup ?? undefined,
complexityMultiplier: Number(shot.complexityMultiplier),
description: shot.description ?? undefined,
durationSeconds: shot.durationSeconds ?? undefined,
notes: shot.notes ?? undefined,
}}
showId={showId}
jobType={jobType}
nextShotNumber={shot.shotNumber}
existingGroups={existingGroups}
/>
<LineItemDialog
open={addLineOpen}
onOpenChange={setAddLineOpen}
onSubmit={handleAddLineItem}
rateCards={rateCards}
defaultTasks={[
"Matchmove / Tracking", "3D Camera Tracking", "Object Tracking", "Roto",
"Paint / Cleanup", "Keying", "Compositing", "FX Simulation", "Lighting",
"Rendering", "Animation", "Lookdev", "Crowd Simulation", "CG Integration",
"Environment Creation", "Matte Painting", "Rig Removal", "Wire Removal",
"Beauty Cleanup", "Screen Replacement", "Set Extension", "QC / Review",
"Supervision", "Editorial Support",
]}
/>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useForm, useWatch } from "react-hook-form";
import type { Resolver } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { shotSchema, type ShotFormValues } from "@/lib/validations";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: ShotFormValues) => Promise<void>;
initialData?: Partial<ShotFormValues>;
showId: string;
jobType: "STANDARD" | "EPISODIC";
nextShotNumber: number;
existingGroups?: string[];
}
export function ShotDialog({ open, onOpenChange, onSubmit, initialData, showId, jobType, nextShotNumber, existingGroups = [] }: Props) {
const {
register,
handleSubmit,
reset,
control,
formState: { errors, isSubmitting },
} = useForm<ShotFormValues>({
resolver: zodResolver(shotSchema) as Resolver<ShotFormValues>,
defaultValues: { complexityMultiplier: 1, ...initialData },
});
const scene = useWatch({ control, name: "scene", defaultValue: "" });
const episode = useWatch({ control, name: "episode", defaultValue: "" });
const paddedNumber = String(nextShotNumber).padStart(4, "0");
const sid = (showId || "UNK").toUpperCase();
const sc = (scene || "SCENE").toUpperCase();
const ep = (episode || "").toUpperCase();
const previewCode =
jobType === "EPISODIC" && ep
? `${sid}_${ep}_${sc}_${paddedNumber}`
: `${sid}_${sc}_${paddedNumber}`;
const handleClose = () => {
reset();
onOpenChange(false);
};
const submit = async (data: ShotFormValues) => {
await onSubmit(data);
reset();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-lg">
<DialogHeader>
<DialogTitle>{initialData ? "Edit Shot" : "Add Shot"}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
{/* Shot code — editable in edit mode, preview-only when adding */}
{initialData ? (
<div className="space-y-1.5">
<Label>Shot Code</Label>
<Input
{...register("shotCode")}
className="bg-zinc-800 border-zinc-700 font-mono uppercase text-amber-400"
placeholder={previewCode}
onChange={e => { e.target.value = e.target.value.toUpperCase(); register("shotCode").onChange(e); }}
/>
{errors.shotCode && <p className="text-red-400 text-xs">{errors.shotCode.message}</p>}
</div>
) : (
<div className="bg-zinc-800/60 border border-zinc-700 rounded-lg px-4 py-3">
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Shot ID preview</p>
<p className="font-mono font-semibold text-amber-400 text-sm">{previewCode}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{jobType === "EPISODIC" && (
<div className="space-y-1.5">
<Label>Episode</Label>
<Input
{...register("episode")}
className="bg-zinc-800 border-zinc-700 uppercase"
placeholder="e.g. EP01"
onChange={e => { e.target.value = e.target.value.toUpperCase(); register("episode").onChange(e); }}
/>
{errors.episode && <p className="text-red-400 text-xs">{errors.episode.message}</p>}
</div>
)}
<div className="space-y-1.5">
<Label>Scene *</Label>
<Input
{...register("scene")}
className="bg-zinc-800 border-zinc-700 uppercase"
placeholder="e.g. SQ010"
onChange={e => { e.target.value = e.target.value.toUpperCase(); register("scene").onChange(e); }}
/>
{errors.scene && <p className="text-red-400 text-xs">{errors.scene.message}</p>}
</div>
{jobType === "STANDARD" && (
<div className="space-y-1.5 col-span-2">
<Label>Group / Package <span className="text-zinc-500 font-normal text-xs ml-1">optional — used for quote subtotals</span></Label>
<input
{...register("shotGroup")}
list="shot-groups"
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-amber-500"
placeholder="e.g. Title Sequence, VFX Pack, Episode 1…"
/>
{existingGroups.length > 0 && (
<datalist id="shot-groups">
{existingGroups.map(g => <option key={g} value={g} />)}
</datalist>
)}
</div>
)}
<div className="space-y-1.5">
<Label>Duration (seconds)</Label>
<Input {...register("durationSeconds")} type="number" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Complexity Multiplier</Label>
<Input {...register("complexityMultiplier")} type="number" step="0.1" min="0.1" max="10" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input {...register("description")} className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} className="bg-amber-500 hover:bg-amber-400 text-black font-semibold">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData ? "Save" : "Add Shot"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useState, useMemo } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Plus, FolderDown, Upload } from "lucide-react";
import { createShot, deleteShot, duplicateShot, reorderShots } from "@/actions/shots-assets";
import { ShotCard } from "./shot-card";
import { ShotDialog } from "./shot-dialog";
import { ImportShotsDialog } from "./import-shots-dialog";
import { ImportCsvShotsDialog } from "./import-csv-shots-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import type { Prisma } from "@prisma/client";
type JobWithRelations = Prisma.JobGetPayload<{
include: {
client: true;
shots: { include: { lineItems: { include: { rateCard: true } } } };
assets: { include: { lineItems: { include: { rateCard: true } } } };
comments: { include: { user: true } };
history: true;
};
}>;
function shotTotal(shot: any): number {
const results = (shot.lineItems ?? []).map((li: any) =>
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",
}, shot.complexityMultiplier?.toString() ?? "1")
);
return sumLineItems(results).total.toNumber();
}
export function ShotsEditor({ job, rateCards, taskTemplates }: { job: JobWithRelations; rateCards: any[]; taskTemplates: any[] }) {
const [shots, setShots] = useState(job.shots);
const [dialogOpen, setDialogOpen] = useState(false);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importCsvDialogOpen, setImportCsvDialogOpen] = useState(false);
const jobType = (job.jobType as "STANDARD" | "EPISODIC") ?? "STANDARD";
const showId = job.showId || "";
const sensors = useSensors(useSensor(PointerSensor));
// Collect unique group keys for autocomplete
const existingGroups = useMemo<string[]>(() => {
const keys = new Set<string>();
shots.forEach((s: any) => {
const g = jobType === "EPISODIC" ? s.episode : s.shotGroup;
if (g) keys.add(g);
});
return Array.from(keys).sort();
}, [shots, jobType]);
// Group shots for display: episodic → by episode, standard → by shotGroup
const grouped = useMemo(() => {
const map = new Map<string, typeof shots>();
shots.forEach((s: any) => {
const key = jobType === "EPISODIC"
? (s.episode || "Ungrouped")
: (s.shotGroup || "Ungrouped");
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
});
// Sort groups alphabetically; put "Ungrouped" last
const result: { label: string; shots: typeof shots }[] = [];
const sortedKeys = Array.from(map.keys()).filter((k) => k !== "Ungrouped").sort();
sortedKeys.forEach((k) => result.push({ label: k, shots: map.get(k)! }));
const ungrouped = map.get("Ungrouped");
if (ungrouped?.length) result.push({ label: "Ungrouped", shots: ungrouped });
// Sort shots within each group alphabetically by shotCode
result.forEach((g) => g.shots.sort((a: any, b: any) => (a.shotCode ?? "").localeCompare(b.shotCode ?? "")));
return result;
}, [shots, jobType]);
const hasGroups = grouped.length > 1 || (grouped.length === 1 && grouped[0].label !== "Ungrouped");
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = shots.findIndex((s) => s.id === active.id);
const newIndex = shots.findIndex((s) => s.id === over.id);
const reordered = arrayMove(shots, oldIndex, newIndex);
setShots(reordered);
await reorderShots(job.id, reordered.map((s) => s.id));
};
const handleCreate = async (data: any) => {
try {
const result = await createShot(job.id, data);
setShots((prev) => [...prev, result.shot as any]);
setDialogOpen(false);
toast.success("Shot added");
} catch {
toast.error("Failed to add shot");
}
};
const handleImported = (importedShots: any[]) => {
setShots((prev) => [...prev, ...importedShots]);
};
const handleDelete = async (shotId: string) => {
try {
await deleteShot(shotId, job.id);
setShots((prev) => prev.filter((s) => s.id !== shotId));
toast.success("Shot deleted");
} catch {
toast.error("Failed to delete shot");
}
};
const handleDuplicate = async (shotId: string) => {
try {
const result = await duplicateShot(shotId, job.id);
setShots((prev) => [...prev, result.shot as any]);
toast.success("Shot duplicated");
} catch {
toast.error("Failed to duplicate shot");
}
};
const handleShotUpdate = (updatedShot: any) => {
setShots((prev) => prev.map((s) => (s.id === updatedShot.id ? updatedShot : s)));
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">Shots ({shots.length})</h2>
<div className="flex items-center gap-2">
<Button
onClick={() => setImportCsvDialogOpen(true)}
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-white"
>
<Upload className="h-4 w-4 mr-1" /> Import CSV
</Button>
<Button
onClick={() => setImportDialogOpen(true)}
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-white"
>
<FolderDown className="h-4 w-4 mr-1" /> Import Shots
</Button>
<Button
onClick={() => setDialogOpen(true)}
size="sm"
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
<Plus className="h-4 w-4 mr-1" /> Add Shot
</Button>
</div>
</div>
{shots.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-dashed border-zinc-700 text-zinc-500">
<p>No shots yet. Add your first shot to get started.</p>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={shots.map((s) => s.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-6">
{grouped.map(({ label, shots: groupShots }) => {
const groupTotal = groupShots.reduce((sum, s) => sum + shotTotal(s), 0);
return (
<div key={label}>
{hasGroups && (
<div className="flex items-center justify-between mb-2 px-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400">
{label}
</span>
<span className="text-xs text-zinc-600">· {groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}</span>
</div>
<span className="font-mono text-sm font-semibold text-amber-400">
{formatCurrency(groupTotal, "ZAR")}
</span>
</div>
)}
<div className="space-y-3">
{groupShots.map((shot) => (
<ShotCard
key={shot.id}
shot={shot as any}
jobId={job.id}
currency="ZAR"
rateCards={rateCards}
taskTemplates={taskTemplates}
showId={showId}
jobType={jobType}
existingGroups={existingGroups}
onDelete={handleDelete}
onDuplicate={handleDuplicate}
onUpdate={handleShotUpdate}
/>
))}
</div>
</div>
);
})}
</div>
</SortableContext>
</DndContext>
)}
<ShotDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSubmit={handleCreate}
showId={showId}
jobType={jobType}
nextShotNumber={Math.max(0, ...shots.map((s: any) => s.shotNumber ?? 0)) + 10}
existingGroups={existingGroups}
/>
<ImportShotsDialog
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
jobId={job.id}
onImported={handleImported}
/>
<ImportCsvShotsDialog
open={importCsvDialogOpen}
onOpenChange={setImportCsvDialogOpen}
jobId={job.id}
onImported={handleImported}
/>
</div>
);
}
@@ -0,0 +1,263 @@
"use client";
import { useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { GripVertical, ChevronDown, ChevronRight, Trash2, Pencil, Plus, MapPin, Calendar, Clock } from "lucide-react";
import { updateSupervision, deleteLineItem, createLineItem } from "@/actions/shots-assets";
import { toast } from "sonner";
import { LineItemsTable } from "@/components/line-items/line-items-table";
import { SupervisionDialog } from "./supervision-dialog";
import { LineItemDialog } from "@/components/line-items/line-item-dialog";
import { calcLineItem, sumLineItems, formatCurrency } from "@/lib/calculations";
import { SUPERVISION_LINE_ITEM_FLAT } from "@/lib/constants";
interface Supervision {
id: string;
supervisionTitle: string;
shootName: string | null;
location: string | null;
shootDates: string | null;
numShootDays: number;
travelDays: number;
prepDays: number;
wrapDays: number;
description: string | null;
complexityMultiplier: any;
notes: string | null;
lineItems: any[];
}
interface Props {
supervision: Supervision;
jobId: string;
currency: string;
rateCards: any[];
onDelete: (id: string) => void;
onUpdate: (supervision: any) => void;
}
export function SupervisionCard({ supervision, jobId, currency, rateCards, onDelete, onUpdate }: Props) {
const [expanded, setExpanded] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [addLineOpen, setAddLineOpen] = useState(false);
const [lineItems, setLineItems] = useState(supervision.lineItems);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: supervision.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const totals = sumLineItems(
lineItems.map((li) =>
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",
}, supervision.complexityMultiplier?.toString() ?? "1")
)
);
const totalDays = supervision.numShootDays + supervision.prepDays + supervision.travelDays + supervision.wrapDays;
const handleEdit = async (data: any) => {
try {
const result = await updateSupervision(supervision.id, jobId, data);
onUpdate(result.supervision);
setEditOpen(false);
toast.success("Supervision updated");
} catch {
toast.error("Failed to update supervision");
}
};
const handleAddLineItem = async (data: any) => {
try {
const result = await createLineItem(supervision.id, "supervision", jobId, data);
setLineItems((prev) => [...prev, result.lineItem]);
setAddLineOpen(false);
toast.success("Line item added");
} catch {
toast.error("Failed to add line item");
}
};
const handleDeleteLineItem = async (id: string) => {
try {
await deleteLineItem(id, jobId);
setLineItems((prev) => prev.filter((li) => li.id !== id));
toast.success("Line item removed");
} catch {
toast.error("Failed to delete line item");
}
};
const handleLineItemUpdate = (updated: any) => {
setLineItems((prev) => prev.map((li) => (li.id === updated.id ? updated : li)));
};
return (
<div ref={setNodeRef} style={style} className="border border-zinc-800 rounded-xl overflow-hidden bg-zinc-900">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-900">
<button {...attributes} {...listeners} className="text-zinc-600 hover:text-zinc-400 cursor-grab">
<GripVertical className="h-4 w-4" />
</button>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-white truncate">{supervision.supervisionTitle}</span>
{supervision.location && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-400">
<MapPin className="h-3 w-3" />
{supervision.location}
</span>
)}
{supervision.shootDates && (
<span className="inline-flex items-center gap-0.5 text-xs text-zinc-500">
<Calendar className="h-3 w-3" />
{supervision.shootDates}
</span>
)}
{totalDays > 0 && (
<span className="inline-flex items-center gap-0.5 text-xs text-amber-500">
<Clock className="h-3 w-3" />
{totalDays}d total
</span>
)}
</div>
{supervision.description && (
<p className="text-xs text-zinc-500 truncate mt-0.5">{supervision.description}</p>
)}
</div>
</button>
<span className="text-sm font-mono font-semibold text-amber-400 shrink-0">
{formatCurrency(totals.total, currency)}
</span>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-zinc-400 hover:text-white"
onClick={() => setEditOpen(true)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-zinc-600 hover:text-red-400"
onClick={() => onDelete(supervision.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* Expanded content */}
{expanded && (
<div className="border-t border-zinc-800">
{/* Day breakdown pills */}
{totalDays > 0 && (
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-950/50 flex-wrap">
{supervision.numShootDays > 0 && (
<span className="text-xs bg-amber-900/30 text-amber-300 px-2 py-0.5 rounded-full border border-amber-800/50">
{supervision.numShootDays} shoot {supervision.numShootDays === 1 ? "day" : "days"}
</span>
)}
{supervision.prepDays > 0 && (
<span className="text-xs bg-zinc-800 text-zinc-300 px-2 py-0.5 rounded-full">
{supervision.prepDays} prep
</span>
)}
{supervision.travelDays > 0 && (
<span className="text-xs bg-zinc-800 text-zinc-300 px-2 py-0.5 rounded-full">
{supervision.travelDays} travel
</span>
)}
{supervision.wrapDays > 0 && (
<span className="text-xs bg-zinc-800 text-zinc-300 px-2 py-0.5 rounded-full">
{supervision.wrapDays} wrap
</span>
)}
</div>
)}
{/* Line items */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
Line Items ({lineItems.length})
</span>
<Button
size="sm"
variant="ghost"
onClick={() => setAddLineOpen(true)}
className="h-7 text-xs text-amber-400 hover:text-amber-300"
>
<Plus className="h-3.5 w-3.5 mr-1" /> Add
</Button>
</div>
{lineItems.length > 0 ? (
<LineItemsTable
lineItems={lineItems}
complexityMultiplier={supervision.complexityMultiplier?.toString() ?? "1"}
currency={currency}
parentId={supervision.id}
parentType="supervision"
jobId={jobId}
rateCards={rateCards}
onDelete={handleDeleteLineItem}
onUpdate={handleLineItemUpdate}
/>
) : (
<p className="text-xs text-zinc-600 py-2">No line items yet.</p>
)}
</div>
</div>
)}
<SupervisionDialog
open={editOpen}
onOpenChange={setEditOpen}
onSubmit={handleEdit}
initialData={{
supervisionTitle: supervision.supervisionTitle,
shootName: supervision.shootName ?? undefined,
location: supervision.location ?? undefined,
shootDates: supervision.shootDates ?? undefined,
numShootDays: supervision.numShootDays,
travelDays: supervision.travelDays,
prepDays: supervision.prepDays,
wrapDays: supervision.wrapDays,
description: supervision.description ?? undefined,
complexityMultiplier: Number(supervision.complexityMultiplier),
notes: supervision.notes ?? undefined,
}}
/>
<LineItemDialog
open={addLineOpen}
onOpenChange={setAddLineOpen}
onSubmit={handleAddLineItem}
rateCards={rateCards}
defaultTasks={SUPERVISION_LINE_ITEM_FLAT as unknown as string[]}
/>
</div>
);
}
@@ -0,0 +1,144 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { supervisionSchema, type SupervisionFormValues } from "@/lib/validations";
import type { Resolver } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
interface Props {
open: boolean;
onOpenChange: (v: boolean) => void;
onSubmit: (data: SupervisionFormValues) => Promise<void>;
initialData?: Partial<SupervisionFormValues>;
}
export function SupervisionDialog({ open, onOpenChange, onSubmit, initialData }: Props) {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<SupervisionFormValues>({
resolver: zodResolver(supervisionSchema) as Resolver<SupervisionFormValues>,
defaultValues: {
complexityMultiplier: 1,
numShootDays: 0,
travelDays: 0,
prepDays: 0,
wrapDays: 0,
...initialData,
},
});
const handleClose = () => { reset(); onOpenChange(false); };
const submit = async (data: SupervisionFormValues) => { await onSubmit(data); reset(); };
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="bg-zinc-900 border-zinc-800 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-white">
{initialData?.supervisionTitle ? "Edit Supervision" : "Add Supervision"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(submit as Parameters<typeof handleSubmit>[0])} className="space-y-4">
{/* Title */}
<div className="space-y-1.5">
<Label>Supervision Title *</Label>
<Input
{...register("supervisionTitle")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. Cape Town Unit Shoot"
/>
{errors.supervisionTitle && <p className="text-red-400 text-xs">{errors.supervisionTitle.message}</p>}
</div>
{/* Shoot name + location */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Production / Shoot Name</Label>
<Input {...register("shootName")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Feature Film Unit B" />
</div>
<div className="space-y-1.5">
<Label>Location</Label>
<Input {...register("location")} className="bg-zinc-800 border-zinc-700" placeholder="e.g. Cape Town" />
</div>
</div>
{/* Shoot dates + complexity */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Shoot Dates</Label>
<Input
{...register("shootDates")}
className="bg-zinc-800 border-zinc-700"
placeholder="e.g. 15–20 Jan 2026"
/>
</div>
<div className="space-y-1.5">
<Label>Complexity Multiplier</Label>
<Input
{...register("complexityMultiplier")}
type="number"
step="0.1"
min="0.1"
max="10"
className="bg-zinc-800 border-zinc-700"
/>
</div>
</div>
{/* Days breakdown */}
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1.5">
<Label>Shoot Days</Label>
<Input {...register("numShootDays")} type="number" min="0" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Prep Days</Label>
<Input {...register("prepDays")} type="number" min="0" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Travel Days</Label>
<Input {...register("travelDays")} type="number" min="0" className="bg-zinc-800 border-zinc-700" />
</div>
<div className="space-y-1.5">
<Label>Wrap Days</Label>
<Input {...register("wrapDays")} type="number" min="0" className="bg-zinc-800 border-zinc-700" />
</div>
</div>
{/* Description + Notes */}
<div className="space-y-1.5">
<Label>Description</Label>
<Input {...register("description")} className="bg-zinc-800 border-zinc-700" placeholder="Brief overview" />
</div>
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea {...register("notes")} className="bg-zinc-800 border-zinc-700" rows={2} />
</div>
<div className="flex gap-3 justify-end pt-2">
<Button type="button" variant="outline" className="border-zinc-700" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{initialData?.supervisionTitle ? "Save Changes" : "Add Supervision"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,121 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { createSupervision, deleteSupervision, reorderSupervisions } from "@/actions/shots-assets";
import { SupervisionCard } from "./supervision-card";
import { SupervisionDialog } from "./supervision-dialog";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
interface Props {
job: {
id: string;
supervisions: any[];
[key: string]: any;
};
rateCards: any[];
}
export function SupervisionEditor({ job, rateCards }: Props) {
const [supervisions, setSupervisions] = useState(job.supervisions ?? []);
const [dialogOpen, setDialogOpen] = useState(false);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = supervisions.findIndex((s) => s.id === active.id);
const newIndex = supervisions.findIndex((s) => s.id === over.id);
const reordered = arrayMove(supervisions, oldIndex, newIndex);
setSupervisions(reordered);
await reorderSupervisions(job.id, reordered.map((s) => s.id));
};
const handleCreate = async (data: any) => {
try {
const result = await createSupervision(job.id, data);
setSupervisions((prev) => [...prev, result.supervision]);
setDialogOpen(false);
toast.success("Supervision added");
} catch {
toast.error("Failed to add supervision");
}
};
const handleDelete = async (supervisionId: string) => {
try {
await deleteSupervision(supervisionId, job.id);
setSupervisions((prev) => prev.filter((s) => s.id !== supervisionId));
toast.success("Supervision deleted");
} catch {
toast.error("Failed to delete supervision");
}
};
const handleUpdate = (updated: any) => {
setSupervisions((prev) => prev.map((s) => (s.id === updated.id ? updated : s)));
};
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-white">On-Set Supervision ({supervisions.length})</h2>
<p className="text-xs text-zinc-500 mt-0.5">Shoots, tech scouts, data wrangling, VP support</p>
</div>
<Button
onClick={() => setDialogOpen(true)}
size="sm"
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold"
>
<Plus className="h-4 w-4 mr-1" /> Add Supervision
</Button>
</div>
{supervisions.length === 0 ? (
<div className="text-center py-12 rounded-xl border border-dashed border-zinc-700 text-zinc-500">
<p className="mb-1">No supervision entries yet.</p>
<p className="text-sm">Add on-set supervision, tech scouts, or VP support services.</p>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={supervisions.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{supervisions.map((sv) => (
<SupervisionCard
key={sv.id}
supervision={sv}
jobId={job.id}
currency="ZAR"
rateCards={rateCards}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
<SupervisionDialog open={dialogOpen} onOpenChange={setDialogOpen} onSubmit={handleCreate} />
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+221
View File
@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+21
View File
@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+196
View File
@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+268
View File
@@ -0,0 +1,268 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+83
View File
@@ -0,0 +1,83 @@
"use client"
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
import { cn } from "@/lib/utils"
function Progress({
className,
children,
value,
...props
}: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
)
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
data-slot="progress-track"
{...props}
/>
)
}
function ProgressIndicator({
className,
...props
}: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
)
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label
className={cn("text-sm font-medium", className)}
data-slot="progress-label"
{...props}
/>
)
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn(
"ml-auto text-sm text-muted-foreground tabular-nums",
className
)}
data-slot="progress-value"
{...props}
/>
)
}
export {
Progress,
ProgressTrack,
ProgressIndicator,
ProgressLabel,
ProgressValue,
}
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }