Add full project files
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientSchema, type ClientFormValues } from "@/lib/validations";
|
||||
|
||||
export async function getClients() {
|
||||
return prisma.client.findMany({
|
||||
orderBy: { companyName: "asc" },
|
||||
include: { _count: { select: { jobs: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getClient(id: string) {
|
||||
return prisma.client.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
jobs: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { _count: { select: { shots: true, assets: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createClient(data: ClientFormValues) {
|
||||
const validated = clientSchema.parse(data);
|
||||
const client = await prisma.client.create({ data: validated });
|
||||
revalidatePath("/clients");
|
||||
return { success: true, client };
|
||||
}
|
||||
|
||||
export async function updateClient(id: string, data: ClientFormValues) {
|
||||
const validated = clientSchema.parse(data);
|
||||
const client = await prisma.client.update({ where: { id }, data: validated });
|
||||
revalidatePath("/clients");
|
||||
revalidatePath(`/clients/${id}`);
|
||||
return { success: true, client };
|
||||
}
|
||||
|
||||
export async function deleteClient(id: string) {
|
||||
await prisma.client.delete({ where: { id } });
|
||||
revalidatePath("/clients");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { invoiceSchema, invoicePaymentSchema, type InvoiceFormValues, type InvoicePaymentFormValues } from "@/lib/validations";
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
// ─── Auto invoice numbering ───────────────────────────────────────────────────
|
||||
|
||||
async function getNextInvoiceNumber(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const latest = await prisma.invoice.findFirst({
|
||||
where: { invoiceNumber: { startsWith: `INV-${year}-` } },
|
||||
orderBy: { invoiceNumber: "desc" },
|
||||
});
|
||||
if (!latest) return `INV-${year}-0001`;
|
||||
const parts = latest.invoiceNumber.split("-");
|
||||
const seq = parseInt(parts[parts.length - 1] ?? "0", 10) + 1;
|
||||
return `INV-${year}-${String(seq).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// ─── Invoice CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getInvoices(filters?: { clientId?: string; jobId?: string; status?: string }) {
|
||||
return prisma.invoice.findMany({
|
||||
where: {
|
||||
...(filters?.clientId && { clientId: filters.clientId }),
|
||||
...(filters?.jobId && { jobId: filters.jobId }),
|
||||
...(filters?.status && { status: filters.status as any }),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
client: { select: { companyName: true } },
|
||||
job: { select: { projectTitle: true, projectCode: true } },
|
||||
payments: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInvoice(id: string) {
|
||||
return prisma.invoice.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
client: true,
|
||||
job: {
|
||||
include: {
|
||||
paymentSchedule: { include: { milestones: true } },
|
||||
},
|
||||
},
|
||||
milestone: true,
|
||||
payments: { orderBy: { paymentDate: "asc" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createInvoice(data: InvoiceFormValues) {
|
||||
const validated = invoiceSchema.parse(data);
|
||||
const taxAmount = new Decimal(validated.subtotal)
|
||||
.mul(new Decimal(validated.taxPercent).div(100))
|
||||
.toFixed(4);
|
||||
const totalAmount = new Decimal(validated.subtotal).add(taxAmount).toFixed(4);
|
||||
|
||||
const invoice = await prisma.invoice.create({
|
||||
data: {
|
||||
...validated,
|
||||
invoiceNumber: validated.invoiceNumber,
|
||||
issueDate: new Date(validated.issueDate),
|
||||
dueDate: validated.dueDate ? new Date(validated.dueDate) : undefined,
|
||||
milestoneId: validated.milestoneId || undefined,
|
||||
subtotal: new Decimal(validated.subtotal),
|
||||
taxPercent: new Decimal(validated.taxPercent),
|
||||
taxAmount: new Decimal(taxAmount),
|
||||
totalAmount: new Decimal(totalAmount),
|
||||
amountPaid: new Decimal(0),
|
||||
amountDue: new Decimal(totalAmount),
|
||||
},
|
||||
});
|
||||
|
||||
// Update milestone status if linked
|
||||
if (validated.milestoneId) {
|
||||
await prisma.paymentMilestone.update({
|
||||
where: { id: validated.milestoneId },
|
||||
data: { status: "INVOICED" },
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/invoices");
|
||||
return { success: true, invoice };
|
||||
}
|
||||
|
||||
export async function updateInvoice(id: string, data: InvoiceFormValues) {
|
||||
const validated = invoiceSchema.parse(data);
|
||||
const taxAmount = new Decimal(validated.subtotal)
|
||||
.mul(new Decimal(validated.taxPercent).div(100))
|
||||
.toFixed(4);
|
||||
const totalAmount = new Decimal(validated.subtotal).add(taxAmount).toFixed(4);
|
||||
|
||||
const existing = await prisma.invoice.findUnique({ where: { id }, include: { payments: true } });
|
||||
const amountPaid = existing?.payments.reduce((sum, p) => sum.add(p.amount), new Decimal(0)) ?? new Decimal(0);
|
||||
const amountDue = new Decimal(totalAmount).sub(amountPaid);
|
||||
|
||||
const invoice = await prisma.invoice.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...validated,
|
||||
issueDate: new Date(validated.issueDate),
|
||||
dueDate: validated.dueDate ? new Date(validated.dueDate) : null,
|
||||
milestoneId: validated.milestoneId || null,
|
||||
subtotal: new Decimal(validated.subtotal),
|
||||
taxPercent: new Decimal(validated.taxPercent),
|
||||
taxAmount: new Decimal(taxAmount),
|
||||
totalAmount: new Decimal(totalAmount),
|
||||
amountDue: amountDue.gt(0) ? amountDue : new Decimal(0),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/invoices");
|
||||
revalidatePath(`/invoices/${id}`);
|
||||
return { success: true, invoice };
|
||||
}
|
||||
|
||||
export async function deleteInvoice(id: string) {
|
||||
await prisma.invoice.delete({ where: { id } });
|
||||
revalidatePath("/invoices");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function nextInvoiceNumber() {
|
||||
return getNextInvoiceNumber();
|
||||
}
|
||||
|
||||
// ─── From milestone ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function createInvoiceFromMilestone(milestoneId: string, jobTotal?: number) {
|
||||
// Check if invoice already exists for this milestone
|
||||
const existingInvoice = await prisma.invoice.findUnique({ where: { milestoneId } });
|
||||
if (existingInvoice) throw new Error("Invoice already exists for this milestone");
|
||||
|
||||
const milestone = await prisma.paymentMilestone.findUnique({
|
||||
where: { id: milestoneId },
|
||||
include: {
|
||||
schedule: {
|
||||
include: {
|
||||
job: { include: { client: true } },
|
||||
milestones: true, // needed to determine remaining uninvoiced milestones
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!milestone) throw new Error("Milestone not found");
|
||||
|
||||
const job = milestone.schedule.job;
|
||||
const effectiveJobTotal = new Decimal(jobTotal ?? 0);
|
||||
const invoiceNumber = await getNextInvoiceNumber();
|
||||
|
||||
// ── Remaining-balance calculation ─────────────────────────────────────────
|
||||
// Sum the subtotals of all non-cancelled invoices already generated for this job.
|
||||
// This ensures sent invoices are never modified — only uninvoiced milestones adjust.
|
||||
const priorInvoices = await prisma.invoice.findMany({
|
||||
where: { jobId: job.id, status: { not: "CANCELLED" } },
|
||||
select: { subtotal: true },
|
||||
});
|
||||
const alreadyInvoicedTotal = priorInvoices.reduce(
|
||||
(sum, inv) => sum.add(new Decimal(inv.subtotal.toString())),
|
||||
new Decimal(0)
|
||||
);
|
||||
|
||||
// Find which milestones in this schedule have already been invoiced
|
||||
const priorMilestoneInvoices = await prisma.invoice.findMany({
|
||||
where: { jobId: job.id, milestoneId: { not: null }, status: { not: "CANCELLED" } },
|
||||
select: { milestoneId: true },
|
||||
});
|
||||
const invoicedMilestoneIds = new Set(priorMilestoneInvoices.map(r => r.milestoneId!));
|
||||
|
||||
// Uninvoiced milestones = not cancelled and no existing invoice (includes current milestone)
|
||||
const uninvoicedMilestones = milestone.schedule.milestones.filter(
|
||||
m => m.status !== "CANCELLED" && !invoicedMilestoneIds.has(m.id)
|
||||
);
|
||||
const totalUninvoicedPercent = uninvoicedMilestones.reduce(
|
||||
(sum, m) => sum + Number(m.percentOfTotal),
|
||||
0
|
||||
);
|
||||
|
||||
// Amount still to be invoiced after previously sent invoices
|
||||
const remainingAmount = effectiveJobTotal.sub(alreadyInvoicedTotal).toDecimalPlaces(2);
|
||||
|
||||
// ── Subtotal calculation ──────────────────────────────────────────────────
|
||||
// Fixed amount overrides all percentage logic.
|
||||
// For percent-based milestones: distribute remaining balance proportionally so that
|
||||
// sum(all milestone invoices) = jobTotal regardless of when shots/assets were added.
|
||||
// The final uninvoiced milestone absorbs any rounding variance.
|
||||
let subtotal: Decimal;
|
||||
if (milestone.fixedAmount && new Decimal(milestone.fixedAmount.toString()).gt(0)) {
|
||||
subtotal = new Decimal(milestone.fixedAmount.toString());
|
||||
} else if (effectiveJobTotal.gt(0)) {
|
||||
if (remainingAmount.gt(0) && totalUninvoicedPercent > 0) {
|
||||
const isLastUninvoiced = uninvoicedMilestones.length === 1;
|
||||
if (isLastUninvoiced) {
|
||||
// Final milestone absorbs the full remaining balance (handles scope changes cleanly)
|
||||
subtotal = remainingAmount;
|
||||
} else {
|
||||
// Proportional share of the remaining balance across uninvoiced milestones
|
||||
subtotal = remainingAmount
|
||||
.mul(new Decimal(milestone.percentOfTotal.toString()).div(totalUninvoicedPercent))
|
||||
.toDecimalPlaces(2);
|
||||
}
|
||||
} else {
|
||||
// All balance already covered by prior invoices
|
||||
subtotal = new Decimal(0);
|
||||
}
|
||||
} else {
|
||||
subtotal = new Decimal(0);
|
||||
}
|
||||
|
||||
const taxPercent = new Decimal(job.taxPercent.toString());
|
||||
const taxAmount = subtotal.mul(taxPercent.div(100)).toDecimalPlaces(2);
|
||||
const totalAmount = subtotal.add(taxAmount);
|
||||
|
||||
const invoice = await prisma.invoice.create({
|
||||
data: {
|
||||
invoiceNumber,
|
||||
clientId: job.clientId,
|
||||
jobId: job.id,
|
||||
milestoneId: milestone.id,
|
||||
status: "DRAFT",
|
||||
issueDate: new Date(),
|
||||
currency: "ZAR",
|
||||
subtotal,
|
||||
taxPercent,
|
||||
taxAmount,
|
||||
totalAmount,
|
||||
amountPaid: new Decimal(0),
|
||||
amountDue: totalAmount,
|
||||
notes: milestone.description ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.paymentMilestone.update({
|
||||
where: { id: milestoneId },
|
||||
data: { status: "INVOICED" },
|
||||
});
|
||||
|
||||
revalidatePath(`/jobs/${job.id}`);
|
||||
revalidatePath("/invoices");
|
||||
return { success: true, invoice };
|
||||
}
|
||||
|
||||
// ─── Payments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function recordPayment(invoiceId: string, data: InvoicePaymentFormValues) {
|
||||
const validated = invoicePaymentSchema.parse(data);
|
||||
const payment = await prisma.invoicePayment.create({
|
||||
data: {
|
||||
invoiceId,
|
||||
amount: new Decimal(validated.amount),
|
||||
paymentDate: new Date(validated.paymentDate),
|
||||
paymentMethod: validated.paymentMethod as any,
|
||||
reference: validated.reference,
|
||||
notes: validated.notes,
|
||||
},
|
||||
});
|
||||
|
||||
// Recalculate amountPaid and amountDue
|
||||
const invoice = await prisma.invoice.findUnique({
|
||||
where: { id: invoiceId },
|
||||
include: { payments: true },
|
||||
});
|
||||
if (!invoice) throw new Error("Invoice not found");
|
||||
|
||||
const amountPaid = invoice.payments.reduce((sum, p) => sum.add(p.amount), new Decimal(0));
|
||||
const amountDue = new Decimal(invoice.totalAmount.toString()).sub(amountPaid);
|
||||
const isFullyPaid = amountDue.lte(0);
|
||||
|
||||
await prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
amountPaid,
|
||||
amountDue: amountDue.gt(0) ? amountDue : new Decimal(0),
|
||||
status: isFullyPaid ? "PAID" : amountPaid.gt(0) ? "PARTIALLY_PAID" : invoice.status,
|
||||
},
|
||||
});
|
||||
|
||||
// Update milestone if linked
|
||||
if (invoice.milestoneId) {
|
||||
await prisma.paymentMilestone.update({
|
||||
where: { id: invoice.milestoneId },
|
||||
data: {
|
||||
status: isFullyPaid ? "PAID" : "PARTIALLY_PAID",
|
||||
paymentReceivedDate: isFullyPaid ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/invoices");
|
||||
revalidatePath(`/invoices/${invoiceId}`);
|
||||
return { success: true, payment };
|
||||
}
|
||||
|
||||
export async function deletePayment(invoiceId: string, paymentId: string) {
|
||||
await prisma.invoicePayment.delete({ where: { id: paymentId } });
|
||||
|
||||
// Recalculate
|
||||
const invoice = await prisma.invoice.findUnique({
|
||||
where: { id: invoiceId },
|
||||
include: { payments: true },
|
||||
});
|
||||
if (!invoice) return { success: true };
|
||||
|
||||
const amountPaid = invoice.payments.reduce((sum, p) => sum.add(p.amount), new Decimal(0));
|
||||
const amountDue = new Decimal(invoice.totalAmount.toString()).sub(amountPaid);
|
||||
|
||||
await prisma.invoice.update({
|
||||
where: { id: invoiceId },
|
||||
data: {
|
||||
amountPaid,
|
||||
amountDue: amountDue.gt(0) ? amountDue : new Decimal(0),
|
||||
status: amountPaid.lte(0) ? "SENT" : "PARTIALLY_PAID",
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/invoices/${invoiceId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── Financial summary ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFinancialSummary() {
|
||||
const invoices = await prisma.invoice.findMany({
|
||||
where: { status: { not: "CANCELLED" } },
|
||||
select: {
|
||||
status: true,
|
||||
totalAmount: true,
|
||||
amountPaid: true,
|
||||
amountDue: true,
|
||||
dueDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
let totalInvoiced = new Decimal(0);
|
||||
let totalPaid = new Decimal(0);
|
||||
let totalOutstanding = new Decimal(0);
|
||||
let totalOverdue = new Decimal(0);
|
||||
|
||||
for (const inv of invoices) {
|
||||
totalInvoiced = totalInvoiced.add(inv.totalAmount.toString());
|
||||
totalPaid = totalPaid.add(inv.amountPaid.toString());
|
||||
const due = new Decimal(inv.amountDue.toString());
|
||||
if (due.gt(0)) {
|
||||
totalOutstanding = totalOutstanding.add(due);
|
||||
if (inv.dueDate && new Date(inv.dueDate) < now) {
|
||||
totalOverdue = totalOverdue.add(due);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalInvoiced: totalInvoiced.toNumber(),
|
||||
totalPaid: totalPaid.toNumber(),
|
||||
totalOutstanding: totalOutstanding.toNumber(),
|
||||
totalOverdue: totalOverdue.toNumber(),
|
||||
invoiceCount: invoices.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { jobSchema, type JobFormValues } from "@/lib/validations";
|
||||
import { calcLineItem } from "@/lib/calculations";
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
export async function getJobs(filters?: { status?: string; clientId?: string; search?: string }) {
|
||||
return prisma.job.findMany({
|
||||
where: {
|
||||
...(filters?.status && { status: filters.status as any }),
|
||||
...(filters?.clientId && { clientId: filters.clientId }),
|
||||
...(filters?.search && {
|
||||
OR: [
|
||||
{ projectTitle: { contains: filters.search, mode: "insensitive" } },
|
||||
{ projectCode: { contains: filters.search, mode: "insensitive" } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
client: { select: { companyName: true, contactPerson: true } },
|
||||
_count: { select: { shots: true, assets: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getJob(id: string) {
|
||||
return prisma.job.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
client: true,
|
||||
shots: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lineItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { rateCard: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lineItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { rateCard: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
supervisions: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lineItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { rateCard: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
previses: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lineItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { rateCard: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
comments: { orderBy: { createdAt: "asc" }, include: { user: true } },
|
||||
history: { orderBy: { changedAt: "desc" }, take: 20 },
|
||||
paymentSchedule: { include: { milestones: { orderBy: { sortOrder: "asc" } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createJob(data: JobFormValues) {
|
||||
const validated = jobSchema.parse(data);
|
||||
const { issueDate, expiryDate, startDate, estimatedCompletionDate, ...rest } = validated;
|
||||
const job = await prisma.job.create({
|
||||
data: {
|
||||
...rest,
|
||||
issueDate: new Date(issueDate),
|
||||
expiryDate: expiryDate ? new Date(expiryDate) : null,
|
||||
startDate: startDate ? new Date(startDate) : null,
|
||||
estimatedCompletionDate: estimatedCompletionDate ? new Date(estimatedCompletionDate) : null,
|
||||
},
|
||||
});
|
||||
revalidatePath("/jobs");
|
||||
return { success: true, job };
|
||||
}
|
||||
|
||||
export async function updateJob(id: string, data: Partial<JobFormValues>) {
|
||||
const { issueDate, expiryDate, startDate, estimatedCompletionDate, ...rest } = data;
|
||||
const job = await prisma.job.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(issueDate ? { issueDate: new Date(issueDate) } : {}),
|
||||
expiryDate: expiryDate ? new Date(expiryDate) : null,
|
||||
...(startDate !== undefined ? { startDate: startDate ? new Date(startDate) : null } : {}),
|
||||
...(estimatedCompletionDate !== undefined ? { estimatedCompletionDate: estimatedCompletionDate ? new Date(estimatedCompletionDate) : null } : {}),
|
||||
},
|
||||
});
|
||||
revalidatePath("/jobs");
|
||||
revalidatePath(`/jobs/${id}`);
|
||||
return { success: true, job };
|
||||
}
|
||||
|
||||
export async function updateJobStatus(id: string, status: string) {
|
||||
const job = await prisma.job.update({
|
||||
where: { id },
|
||||
data: { status: status as any },
|
||||
});
|
||||
revalidatePath("/jobs");
|
||||
revalidatePath(`/jobs/${id}`);
|
||||
return { success: true, job };
|
||||
}
|
||||
|
||||
export async function deleteJob(id: string) {
|
||||
await prisma.job.delete({ where: { id } });
|
||||
revalidatePath("/jobs");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function duplicateJob(id: string) {
|
||||
const original = await prisma.job.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
shots: { include: { lineItems: true } },
|
||||
assets: { include: { lineItems: true } },
|
||||
supervisions: { include: { lineItems: true } },
|
||||
previses: { include: { lineItems: true } },
|
||||
},
|
||||
});
|
||||
if (!original) throw new Error("Job not found");
|
||||
|
||||
const { id: _id, createdAt: _ca, updatedAt: _ua, projectCode, version, ...rest } = original;
|
||||
|
||||
const newJob = await prisma.job.create({
|
||||
data: {
|
||||
...rest,
|
||||
projectCode: `${projectCode}-v${version + 1}`,
|
||||
version: version + 1,
|
||||
parentJobId: id,
|
||||
status: "DRAFT",
|
||||
shots: {
|
||||
create: original.shots.map(({ id: _sid, jobId: _jid, createdAt: _sca, updatedAt: _sua, ...shot }) => ({
|
||||
...shot,
|
||||
lineItems: {
|
||||
create: shot.lineItems.map(({ id: _lid, shotId: _shid, assetId: _aid, supervisionId: _svid, previsId: _pvid, createdAt: _lca, updatedAt: _lua, ...li }) => li),
|
||||
},
|
||||
})),
|
||||
},
|
||||
assets: {
|
||||
create: original.assets.map(({ id: _aid, jobId: _jid, createdAt: _aca, updatedAt: _aua, ...asset }) => ({
|
||||
...asset,
|
||||
lineItems: {
|
||||
create: asset.lineItems.map(({ id: _lid, shotId: _shid, assetId: _aid2, supervisionId: _svid, previsId: _pvid, createdAt: _lca, updatedAt: _lua, ...li }) => li),
|
||||
},
|
||||
})),
|
||||
},
|
||||
supervisions: {
|
||||
create: original.supervisions.map(({ id: _svid, jobId: _jid, createdAt: _svca, updatedAt: _svua, ...supervision }) => ({
|
||||
...supervision,
|
||||
lineItems: {
|
||||
create: supervision.lineItems.map(({ id: _lid, shotId: _shid, assetId: _aid, supervisionId: _svid2, previsId: _pvid, createdAt: _lca, updatedAt: _lua, ...li }) => li),
|
||||
},
|
||||
})),
|
||||
},
|
||||
previses: {
|
||||
create: original.previses.map(({ id: _pvid, jobId: _jid, createdAt: _pvca, updatedAt: _pvua, ...previs }) => ({
|
||||
...previs,
|
||||
lineItems: {
|
||||
create: previs.lineItems.map(({ id: _lid, shotId: _shid, assetId: _aid, supervisionId: _svid, previsId: _pvid2, createdAt: _lca, updatedAt: _lua, ...li }) => li),
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/jobs");
|
||||
return { success: true, job: newJob };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate all line item totals server-side before PDF generation.
|
||||
* This prevents floating-point drift in stored values.
|
||||
*/
|
||||
export async function recalculateJobTotals(jobId: string) {
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id: jobId },
|
||||
include: {
|
||||
shots: { include: { lineItems: true } },
|
||||
assets: { include: { lineItems: true } },
|
||||
supervisions: { include: { lineItems: true } },
|
||||
previses: { include: { lineItems: true } },
|
||||
},
|
||||
});
|
||||
if (!job) throw new Error("Job not found");
|
||||
|
||||
const updates: Promise<unknown>[] = [];
|
||||
|
||||
const allLineItems = [
|
||||
...job.shots.flatMap((s) => s.lineItems.map((li) => ({ ...li, complexityMultiplier: s.complexityMultiplier }))),
|
||||
...job.assets.flatMap((a) => a.lineItems.map((li) => ({ ...li, complexityMultiplier: a.complexityMultiplier }))),
|
||||
...job.supervisions.flatMap((sv) => sv.lineItems.map((li) => ({ ...li, complexityMultiplier: sv.complexityMultiplier }))),
|
||||
...job.previses.flatMap((pv) => pv.lineItems.map((li) => ({ ...li, complexityMultiplier: pv.complexityMultiplier }))),
|
||||
];
|
||||
|
||||
for (const li of allLineItems) {
|
||||
const result = 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(),
|
||||
},
|
||||
li.complexityMultiplier.toString()
|
||||
);
|
||||
|
||||
updates.push(
|
||||
prisma.lineItem.update({
|
||||
where: { id: li.id },
|
||||
data: {
|
||||
hourlyRate: result.hourlyRate.toFixed(4),
|
||||
subtotal: result.subtotal.toFixed(4),
|
||||
markupAmount: result.markupAmount.toFixed(4),
|
||||
discountAmount: result.discountAmount.toFixed(4),
|
||||
taxAmount: result.taxAmount.toFixed(4),
|
||||
total: result.total.toFixed(4),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(updates);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { milestoneSchema, type MilestoneFormValues } from "@/lib/validations";
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
// ─── Schedule CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getPaymentSchedule(jobId: string) {
|
||||
return prisma.paymentSchedule.findUnique({
|
||||
where: { jobId },
|
||||
include: {
|
||||
milestones: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrUpdateSchedule(
|
||||
jobId: string,
|
||||
template: string,
|
||||
notes?: string
|
||||
) {
|
||||
const schedule = await prisma.paymentSchedule.upsert({
|
||||
where: { jobId },
|
||||
create: { jobId, template: template as any, notes },
|
||||
update: { template: template as any, notes },
|
||||
include: { milestones: { orderBy: { sortOrder: "asc" } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, schedule };
|
||||
}
|
||||
|
||||
// ─── Milestone CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function addMilestone(jobId: string, scheduleId: string, data: MilestoneFormValues) {
|
||||
const validated = milestoneSchema.parse(data);
|
||||
const count = await prisma.paymentMilestone.count({ where: { scheduleId } });
|
||||
const milestone = await prisma.paymentMilestone.create({
|
||||
data: {
|
||||
scheduleId,
|
||||
title: validated.title,
|
||||
description: validated.description,
|
||||
dueDate: validated.dueDate ? new Date(validated.dueDate) : undefined,
|
||||
percentOfTotal: new Decimal(validated.percentOfTotal),
|
||||
fixedAmount: validated.fixedAmount != null ? new Decimal(validated.fixedAmount) : undefined,
|
||||
status: validated.status as any,
|
||||
paymentReceivedDate: validated.paymentReceivedDate
|
||||
? new Date(validated.paymentReceivedDate)
|
||||
: undefined,
|
||||
notes: validated.notes,
|
||||
sortOrder: count,
|
||||
},
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, milestone };
|
||||
}
|
||||
|
||||
export async function updateMilestone(jobId: string, milestoneId: string, data: MilestoneFormValues) {
|
||||
const validated = milestoneSchema.parse(data);
|
||||
const milestone = await prisma.paymentMilestone.update({
|
||||
where: { id: milestoneId },
|
||||
data: {
|
||||
title: validated.title,
|
||||
description: validated.description,
|
||||
dueDate: validated.dueDate ? new Date(validated.dueDate) : null,
|
||||
percentOfTotal: new Decimal(validated.percentOfTotal),
|
||||
fixedAmount: validated.fixedAmount != null ? new Decimal(validated.fixedAmount) : null,
|
||||
status: validated.status as any,
|
||||
paymentReceivedDate: validated.paymentReceivedDate
|
||||
? new Date(validated.paymentReceivedDate)
|
||||
: null,
|
||||
notes: validated.notes,
|
||||
},
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, milestone };
|
||||
}
|
||||
|
||||
export async function deleteMilestone(jobId: string, milestoneId: string) {
|
||||
await prisma.paymentMilestone.delete({ where: { id: milestoneId } });
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderMilestones(jobId: string, orderedIds: string[]) {
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) =>
|
||||
prisma.paymentMilestone.update({ where: { id }, data: { sortOrder: index } })
|
||||
)
|
||||
);
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── Schedule Generator ───────────────────────────────────────────────────────
|
||||
|
||||
type GeneratorOptions = {
|
||||
template: "STANDARD_FILM" | "SMALL_COMMERCIAL" | "EPISODIC" | "CUSTOM";
|
||||
jobTotal: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
depositPercent?: number;
|
||||
finalPercent?: number;
|
||||
monthlyCount?: number;
|
||||
};
|
||||
|
||||
export async function generateScheduleFromTemplate(jobId: string, opts: GeneratorOptions) {
|
||||
// Ensure schedule exists
|
||||
const schedule = await prisma.paymentSchedule.upsert({
|
||||
where: { jobId },
|
||||
create: { jobId, template: opts.template },
|
||||
update: { template: opts.template },
|
||||
});
|
||||
|
||||
// Delete existing milestones
|
||||
await prisma.paymentMilestone.deleteMany({ where: { scheduleId: schedule.id } });
|
||||
|
||||
const milestones: { title: string; percentOfTotal: number; description?: string; dueDate?: Date }[] = [];
|
||||
|
||||
const start = opts.startDate ? new Date(opts.startDate) : new Date();
|
||||
const end = opts.endDate ? new Date(opts.endDate) : null;
|
||||
|
||||
// Calculate number of months between start and end
|
||||
const monthsBetween =
|
||||
end
|
||||
? Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30)))
|
||||
: opts.monthlyCount ?? 3;
|
||||
|
||||
const addMonths = (date: Date, n: number): Date => {
|
||||
const d = new Date(date);
|
||||
d.setMonth(d.getMonth() + n);
|
||||
return d;
|
||||
};
|
||||
|
||||
if (opts.template === "STANDARD_FILM") {
|
||||
const deposit = opts.depositPercent ?? 50;
|
||||
const final = opts.finalPercent ?? 10;
|
||||
const monthly = 100 - deposit - final;
|
||||
const monthlyEach = monthsBetween > 0 ? monthly / monthsBetween : monthly;
|
||||
|
||||
milestones.push({ title: "Deposit", percentOfTotal: deposit, description: "Upfront deposit on project commencement", dueDate: start });
|
||||
for (let i = 1; i <= monthsBetween; i++) {
|
||||
milestones.push({
|
||||
title: `Month ${i} Progress`,
|
||||
percentOfTotal: Math.round(monthlyEach * 100) / 100,
|
||||
description: `Monthly progress payment`,
|
||||
dueDate: addMonths(start, i),
|
||||
});
|
||||
}
|
||||
milestones.push({ title: "Final Delivery Settlement", percentOfTotal: final, description: "Final payment on project delivery", dueDate: end ?? addMonths(start, monthsBetween + 1) });
|
||||
} else if (opts.template === "SMALL_COMMERCIAL") {
|
||||
const deposit = opts.depositPercent ?? 70;
|
||||
milestones.push({ title: "Deposit", percentOfTotal: deposit, description: "Upfront deposit", dueDate: start });
|
||||
milestones.push({ title: "Final Delivery", percentOfTotal: 100 - deposit, description: "Payment on final delivery", dueDate: end ?? addMonths(start, 2) });
|
||||
} else if (opts.template === "EPISODIC") {
|
||||
const deposit = opts.depositPercent ?? 25;
|
||||
const final = opts.finalPercent ?? 10;
|
||||
const monthly = 100 - deposit - final;
|
||||
const monthlyEach = monthsBetween > 0 ? monthly / monthsBetween : monthly;
|
||||
|
||||
milestones.push({ title: "Initial Deposit", percentOfTotal: deposit, description: "Project commencement deposit", dueDate: start });
|
||||
for (let i = 1; i <= monthsBetween; i++) {
|
||||
milestones.push({
|
||||
title: `Episode Batch ${i}`,
|
||||
percentOfTotal: Math.round(monthlyEach * 100) / 100,
|
||||
description: `Monthly billing`,
|
||||
dueDate: addMonths(start, i),
|
||||
});
|
||||
}
|
||||
milestones.push({ title: "Final Completion", percentOfTotal: final, description: "Series completion payment", dueDate: end ?? addMonths(start, monthsBetween + 1) });
|
||||
}
|
||||
|
||||
if (milestones.length > 0) {
|
||||
await prisma.paymentMilestone.createMany({
|
||||
data: milestones.map((m, i) => ({
|
||||
scheduleId: schedule.id,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
percentOfTotal: new Decimal(m.percentOfTotal),
|
||||
dueDate: m.dueDate,
|
||||
status: "PENDING",
|
||||
sortOrder: i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
const result = await prisma.paymentSchedule.findUnique({
|
||||
where: { jobId },
|
||||
include: { milestones: { orderBy: { sortOrder: "asc" } } },
|
||||
});
|
||||
return { success: true, schedule: result };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rateCardSchema, type RateCardFormValues } from "@/lib/validations";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function serializeRateCard(rc: any) {
|
||||
return {
|
||||
...rc,
|
||||
defaultDailyRate: Number(rc.defaultDailyRate),
|
||||
hoursPerDay: Number(rc.hoursPerDay),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRateCards() {
|
||||
const cards = await prisma.rateCard.findMany({
|
||||
orderBy: [{ department: "asc" }, { name: "asc" }],
|
||||
where: { isActive: true },
|
||||
});
|
||||
return cards.map(serializeRateCard);
|
||||
}
|
||||
|
||||
export async function getAllRateCards() {
|
||||
const cards = await prisma.rateCard.findMany({ orderBy: [{ department: "asc" }, { name: "asc" }] });
|
||||
return cards.map(serializeRateCard);
|
||||
}
|
||||
|
||||
export async function createRateCard(data: RateCardFormValues) {
|
||||
const validated = rateCardSchema.parse(data);
|
||||
const rateCard = await prisma.rateCard.create({ data: validated });
|
||||
revalidatePath("/rate-cards");
|
||||
return { success: true, rateCard };
|
||||
}
|
||||
|
||||
export async function updateRateCard(id: string, data: RateCardFormValues) {
|
||||
const validated = rateCardSchema.parse(data);
|
||||
const rateCard = await prisma.rateCard.update({ where: { id }, data: validated });
|
||||
revalidatePath("/rate-cards");
|
||||
return { success: true, rateCard };
|
||||
}
|
||||
|
||||
export async function deleteRateCard(id: string) {
|
||||
await prisma.rateCard.delete({ where: { id } });
|
||||
revalidatePath("/rate-cards");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { studioSettingsSchema } from "@/lib/validations";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function getStudioSettings() {
|
||||
let settings = await prisma.studioSettings.findUnique({ where: { id: "default" } });
|
||||
if (!settings) {
|
||||
settings = await prisma.studioSettings.create({ data: { id: "default" } });
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export async function updateStudioSettings(data: unknown) {
|
||||
const validated = studioSettingsSchema.parse(data);
|
||||
const settings = await prisma.studioSettings.upsert({
|
||||
where: { id: "default" },
|
||||
update: {
|
||||
...validated,
|
||||
studioEmail: validated.studioEmail || null,
|
||||
},
|
||||
create: {
|
||||
id: "default",
|
||||
...validated,
|
||||
studioEmail: validated.studioEmail || null,
|
||||
},
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
revalidatePath("/", "layout");
|
||||
return { success: true, settings };
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { shotSchema, assetSchema, supervisionSchema, previsSchema, lineItemSchema, taskTemplateSchema } from "@/lib/validations";
|
||||
import { calcLineItem } from "@/lib/calculations";
|
||||
|
||||
// ─── External Shots API ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ExternalShot {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
scene: string;
|
||||
episode: string | null;
|
||||
shotNumber: number;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
frameStart: number | null;
|
||||
frameEnd: number | null;
|
||||
fps: number | null;
|
||||
dueDate: string | null;
|
||||
artist: { id: string; name: string; email: string } | null;
|
||||
_count: { versions: number; tasks: number };
|
||||
}
|
||||
|
||||
export async function fetchExternalShots(projectId: string, episode?: string) {
|
||||
const apiKey = process.env.SHOTS_API_KEY;
|
||||
if (!apiKey) throw new Error("SHOTS_API_KEY is not configured on this server");
|
||||
|
||||
const url = new URL("https://review.twotalesvfx.com/api/ext/shots");
|
||||
url.searchParams.set("projectId", projectId.trim());
|
||||
if (episode) url.searchParams.set("episode", episode.trim());
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (res.status === 401) throw new Error("Invalid API key — check SHOTS_API_KEY");
|
||||
if (res.status === 400) throw new Error("Invalid or missing project ID");
|
||||
if (res.status === 404) throw new Error("Project not found");
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
|
||||
const data = await res.json() as { shots: ExternalShot[]; total: number };
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function importShotsFromApi(jobId: string, shots: ExternalShot[]) {
|
||||
if (!shots.length) return { success: true, shots: [] };
|
||||
|
||||
const job = await prisma.job.findUnique({ where: { id: jobId }, select: { showId: true, jobType: true } });
|
||||
if (!job) throw new Error("Job not found");
|
||||
|
||||
// Check for existing shot codes in this job to avoid duplicates
|
||||
const existingCodes = await prisma.shot.findMany({
|
||||
where: { jobId, shotCode: { in: shots.map((s) => s.shotCode) } },
|
||||
select: { shotCode: true },
|
||||
});
|
||||
const existingSet = new Set(existingCodes.map((s) => s.shotCode));
|
||||
const toCreate = shots.filter((s) => !existingSet.has(s.shotCode));
|
||||
|
||||
if (!toCreate.length) return { success: true, shots: [], skipped: shots.length };
|
||||
|
||||
const baseCount = await prisma.shot.count({ where: { jobId } });
|
||||
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s, i) =>
|
||||
prisma.shot.create({
|
||||
data: {
|
||||
jobId,
|
||||
shotCode: s.shotCode,
|
||||
scene: s.scene,
|
||||
episode: s.episode ?? null,
|
||||
description: s.description ?? null,
|
||||
shotNumber: s.shotNumber,
|
||||
sortOrder: baseCount + i,
|
||||
},
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return {
|
||||
success: true,
|
||||
shots: JSON.parse(JSON.stringify(created)),
|
||||
skipped: existingSet.size,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CsvShotRow {
|
||||
shotCode: string;
|
||||
durationSeconds?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function importShotsFromCsv(jobId: string, rows: CsvShotRow[]) {
|
||||
if (!rows.length) return { success: true, shots: [], skipped: 0 };
|
||||
|
||||
// Check for existing shot codes to avoid duplicates
|
||||
const existingCodes = await prisma.shot.findMany({
|
||||
where: { jobId, shotCode: { in: rows.map((r) => r.shotCode) } },
|
||||
select: { shotCode: true },
|
||||
});
|
||||
const existingSet = new Set(existingCodes.map((s) => s.shotCode));
|
||||
const toCreate = rows.filter((r) => !existingSet.has(r.shotCode));
|
||||
|
||||
if (!toCreate.length) return { success: true, shots: [], skipped: rows.length };
|
||||
|
||||
const baseCount = await prisma.shot.count({ where: { jobId } });
|
||||
|
||||
const created = await Promise.all(
|
||||
toCreate.map((r, i) =>
|
||||
prisma.shot.create({
|
||||
data: {
|
||||
jobId,
|
||||
shotCode: r.shotCode,
|
||||
scene: "SC",
|
||||
description: r.description ?? null,
|
||||
durationSeconds: r.durationSeconds ?? null,
|
||||
shotNumber: (baseCount + i + 1) * 10,
|
||||
sortOrder: baseCount + i,
|
||||
},
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return {
|
||||
success: true,
|
||||
shots: JSON.parse(JSON.stringify(created)),
|
||||
skipped: existingSet.size,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Shots ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createShot(jobId: string, data: unknown) {
|
||||
const validated = shotSchema.parse(data);
|
||||
|
||||
// Fetch job to get showId and jobType for shot code generation
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id: jobId },
|
||||
select: { showId: true, jobType: true },
|
||||
});
|
||||
if (!job) throw new Error("Job not found");
|
||||
|
||||
// Auto-increment shot number scoped to this scene (and episode for episodic)
|
||||
const sceneKey = (validated.scene || "SC").toUpperCase();
|
||||
const episodeKey = validated.episode ? validated.episode.toUpperCase() : null;
|
||||
const agg = await prisma.shot.aggregate({
|
||||
where: {
|
||||
jobId,
|
||||
scene: sceneKey,
|
||||
...(job.jobType === "EPISODIC" ? { episode: episodeKey } : {}),
|
||||
},
|
||||
_max: { shotNumber: true },
|
||||
});
|
||||
const shotNumber = (agg._max.shotNumber ?? 0) + 10;
|
||||
const paddedNumber = String(shotNumber).padStart(4, "0");
|
||||
|
||||
// Build shot code
|
||||
const showId = (job.showId || "UNK").toUpperCase();
|
||||
const scene = (validated.scene || "SC").toUpperCase();
|
||||
const shotCode =
|
||||
job.jobType === "EPISODIC" && validated.episode
|
||||
? `${showId}_${validated.episode.toUpperCase()}_${scene}_${paddedNumber}`
|
||||
: `${showId}_${scene}_${paddedNumber}`;
|
||||
|
||||
const count = await prisma.shot.count({ where: { jobId } });
|
||||
const shot = await prisma.shot.create({
|
||||
data: {
|
||||
...validated,
|
||||
jobId,
|
||||
shotCode,
|
||||
shotNumber,
|
||||
sortOrder: count,
|
||||
},
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, shot: JSON.parse(JSON.stringify(shot)) };
|
||||
}
|
||||
|
||||
export async function updateShot(shotId: string, jobId: string, data: unknown) {
|
||||
const validated = shotSchema.parse(data);
|
||||
const { shotCode: customShotCode, ...rest } = validated;
|
||||
const shot = await prisma.shot.update({
|
||||
where: { id: shotId },
|
||||
data: customShotCode ? { ...rest, shotCode: customShotCode.trim() } : rest,
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, shot: JSON.parse(JSON.stringify(shot)) };
|
||||
}
|
||||
|
||||
export async function deleteShot(shotId: string, jobId: string) {
|
||||
await prisma.shot.delete({ where: { id: shotId } });
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function duplicateShot(shotId: string, jobId: string) {
|
||||
const original = await prisma.shot.findUnique({
|
||||
where: { id: shotId },
|
||||
include: { lineItems: true },
|
||||
});
|
||||
if (!original) throw new Error("Shot not found");
|
||||
|
||||
// Auto-increment shot number scoped to the same scene (and episode)
|
||||
const agg = await prisma.shot.aggregate({
|
||||
where: {
|
||||
jobId,
|
||||
scene: original.scene,
|
||||
...(original.episode != null ? { episode: original.episode } : {}),
|
||||
},
|
||||
_max: { shotNumber: true },
|
||||
});
|
||||
const shotNumber = (agg._max.shotNumber ?? 0) + 10;
|
||||
const paddedNumber = String(shotNumber).padStart(4, "0");
|
||||
// Rebuild shotCode preserving the original structure, just with new number
|
||||
// Replace the last 4-digit segment in the code
|
||||
const shotCode = original.shotCode.replace(/\d{4}$/, paddedNumber);
|
||||
|
||||
const { id: _id, createdAt: _ca, updatedAt: _ua, sortOrder, ...rest } = original;
|
||||
const shot = await prisma.shot.create({
|
||||
data: {
|
||||
...rest,
|
||||
shotCode,
|
||||
shotNumber,
|
||||
sortOrder: sortOrder + 1,
|
||||
lineItems: {
|
||||
create: original.lineItems.map(
|
||||
({ id: _lid, shotId: _sid, assetId: _aid, createdAt: _lca, updatedAt: _lua, ...li }) => li
|
||||
),
|
||||
},
|
||||
},
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, shot: JSON.parse(JSON.stringify(shot)) };
|
||||
}
|
||||
|
||||
export async function reorderShots(jobId: string, orderedIds: string[]) {
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) => prisma.shot.update({ where: { id }, data: { sortOrder: index } }))
|
||||
);
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── Assets ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createAsset(jobId: string, data: unknown) {
|
||||
const validated = assetSchema.parse(data);
|
||||
const count = await prisma.asset.count({ where: { jobId } });
|
||||
const asset = await prisma.asset.create({
|
||||
data: { ...validated, jobId, sortOrder: count },
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, asset };
|
||||
}
|
||||
|
||||
export async function updateAsset(assetId: string, jobId: string, data: unknown) {
|
||||
const validated = assetSchema.parse(data);
|
||||
const asset = await prisma.asset.update({
|
||||
where: { id: assetId },
|
||||
data: validated,
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, asset };
|
||||
}
|
||||
|
||||
export async function deleteAsset(assetId: string, jobId: string) {
|
||||
await prisma.asset.delete({ where: { id: assetId } });
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderAssets(jobId: string, orderedIds: string[]) {
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) => prisma.asset.update({ where: { id }, data: { sortOrder: index } }))
|
||||
);
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── Supervision ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createSupervision(jobId: string, data: unknown) {
|
||||
const validated = supervisionSchema.parse(data);
|
||||
const count = await prisma.supervision.count({ where: { jobId } });
|
||||
const supervision = await prisma.supervision.create({
|
||||
data: { ...validated, jobId, sortOrder: count },
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, supervision: JSON.parse(JSON.stringify(supervision)) };
|
||||
}
|
||||
|
||||
export async function updateSupervision(supervisionId: string, jobId: string, data: unknown) {
|
||||
const validated = supervisionSchema.parse(data);
|
||||
const supervision = await prisma.supervision.update({
|
||||
where: { id: supervisionId },
|
||||
data: validated,
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, supervision: JSON.parse(JSON.stringify(supervision)) };
|
||||
}
|
||||
|
||||
export async function deleteSupervision(supervisionId: string, jobId: string) {
|
||||
await prisma.supervision.delete({ where: { id: supervisionId } });
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderSupervisions(jobId: string, orderedIds: string[]) {
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) => prisma.supervision.update({ where: { id }, data: { sortOrder: index } }))
|
||||
);
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── Previs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createPrevis(jobId: string, data: unknown) {
|
||||
const validated = previsSchema.parse(data);
|
||||
const count = await prisma.previs.count({ where: { jobId } });
|
||||
const previs = await prisma.previs.create({
|
||||
data: { ...validated, jobId, sortOrder: count },
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, previs: JSON.parse(JSON.stringify(previs)) };
|
||||
}
|
||||
|
||||
export async function updatePrevis(previsId: string, jobId: string, data: unknown) {
|
||||
const validated = previsSchema.parse(data);
|
||||
const previs = await prisma.previs.update({
|
||||
where: { id: previsId },
|
||||
data: validated,
|
||||
include: { lineItems: { include: { rateCard: true } } },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, previs: JSON.parse(JSON.stringify(previs)) };
|
||||
}
|
||||
|
||||
export async function deletePrevis(previsId: string, jobId: string) {
|
||||
await prisma.previs.delete({ where: { id: previsId } });
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderPrevises(jobId: string, orderedIds: string[]) {
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) => prisma.previs.update({ where: { id }, data: { sortOrder: index } }))
|
||||
);
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── Line Items ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createLineItem(
|
||||
parentId: string,
|
||||
parentType: "shot" | "asset" | "supervision" | "previs",
|
||||
jobId: string,
|
||||
data: unknown
|
||||
) {
|
||||
const validated = lineItemSchema.parse(data);
|
||||
const count =
|
||||
parentType === "shot"
|
||||
? await prisma.lineItem.count({ where: { shotId: parentId } })
|
||||
: parentType === "asset"
|
||||
? await prisma.lineItem.count({ where: { assetId: parentId } })
|
||||
: parentType === "previs"
|
||||
? await prisma.lineItem.count({ where: { previsId: parentId } })
|
||||
: await prisma.lineItem.count({ where: { supervisionId: parentId } });
|
||||
|
||||
// Get complexity multiplier from parent
|
||||
let complexityMultiplier = "1";
|
||||
if (parentType === "shot") {
|
||||
const shot = await prisma.shot.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
|
||||
complexityMultiplier = shot?.complexityMultiplier.toString() ?? "1";
|
||||
} else if (parentType === "asset") {
|
||||
const asset = await prisma.asset.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
|
||||
complexityMultiplier = asset?.complexityMultiplier.toString() ?? "1";
|
||||
} else if (parentType === "previs") {
|
||||
const previs = await prisma.previs.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
|
||||
complexityMultiplier = previs?.complexityMultiplier.toString() ?? "1";
|
||||
} else {
|
||||
const supervision = await prisma.supervision.findUnique({ where: { id: parentId }, select: { complexityMultiplier: true } });
|
||||
complexityMultiplier = supervision?.complexityMultiplier.toString() ?? "1";
|
||||
}
|
||||
|
||||
const calc = calcLineItem(validated as any, complexityMultiplier);
|
||||
|
||||
const lineItem = await prisma.lineItem.create({
|
||||
data: {
|
||||
...validated,
|
||||
...(parentType === "shot"
|
||||
? { shotId: parentId }
|
||||
: parentType === "asset"
|
||||
? { assetId: parentId }
|
||||
: parentType === "previs"
|
||||
? { previsId: parentId }
|
||||
: { supervisionId: parentId }),
|
||||
sortOrder: count,
|
||||
hourlyRate: calc.hourlyRate.toFixed(4),
|
||||
subtotal: calc.subtotal.toFixed(4),
|
||||
markupAmount: calc.markupAmount.toFixed(4),
|
||||
discountAmount: calc.discountAmount.toFixed(4),
|
||||
taxAmount: calc.taxAmount.toFixed(4),
|
||||
total: calc.total.toFixed(4),
|
||||
},
|
||||
include: { rateCard: true },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, lineItem: JSON.parse(JSON.stringify(lineItem)) };
|
||||
}
|
||||
|
||||
export async function updateLineItem(
|
||||
lineItemId: string,
|
||||
jobId: string,
|
||||
data: unknown,
|
||||
complexityMultiplier?: string
|
||||
) {
|
||||
const validated = lineItemSchema.parse(data);
|
||||
const calc = calcLineItem(validated as any, complexityMultiplier ?? "1");
|
||||
|
||||
const lineItem = await prisma.lineItem.update({
|
||||
where: { id: lineItemId },
|
||||
data: {
|
||||
...validated,
|
||||
hourlyRate: calc.hourlyRate.toFixed(4),
|
||||
subtotal: calc.subtotal.toFixed(4),
|
||||
markupAmount: calc.markupAmount.toFixed(4),
|
||||
discountAmount: calc.discountAmount.toFixed(4),
|
||||
taxAmount: calc.taxAmount.toFixed(4),
|
||||
total: calc.total.toFixed(4),
|
||||
},
|
||||
include: { rateCard: true },
|
||||
});
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true, lineItem: JSON.parse(JSON.stringify(lineItem)) };
|
||||
}
|
||||
|
||||
export async function deleteLineItem(lineItemId: string, jobId: string) {
|
||||
await prisma.lineItem.delete({ where: { id: lineItemId } });
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderLineItems(parentId: string, parentType: "shot" | "asset" | "supervision" | "previs", jobId: string, orderedIds: string[]) {
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) => prisma.lineItem.update({ where: { id }, data: { sortOrder: index } }))
|
||||
);
|
||||
revalidatePath(`/jobs/${jobId}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ─── Task Templates ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTaskTemplates() {
|
||||
const templates = await prisma.taskTemplate.findMany({ orderBy: { name: "asc" } });
|
||||
return JSON.parse(JSON.stringify(templates));
|
||||
}
|
||||
|
||||
export async function createTaskTemplate(data: unknown) {
|
||||
const validated = taskTemplateSchema.parse(data);
|
||||
const template = await prisma.taskTemplate.create({ data: validated });
|
||||
return { success: true, template: JSON.parse(JSON.stringify(template)) };
|
||||
}
|
||||
|
||||
export async function deleteTaskTemplate(id: string) {
|
||||
await prisma.taskTemplate.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recalculateJobTotals } from "@/actions/jobs";
|
||||
import { calcLineItem, sumLineItems, applyJobAdjustments, formatCurrency } from "@/lib/calculations";
|
||||
import Decimal from "decimal.js";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
// Recalculate all line item totals server-side before generating PDF
|
||||
await recalculateJobTotals(id);
|
||||
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
client: true,
|
||||
shots: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lineItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { rateCard: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lineItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { rateCard: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
supervisions: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lineItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { rateCard: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
previses: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lineItems: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { rateCard: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const studio = await prisma.studioSettings.findFirst();
|
||||
const currency = "ZAR";
|
||||
|
||||
// Build calculation data
|
||||
const shotData = 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())
|
||||
);
|
||||
const totals = sumLineItems(results);
|
||||
return { shot, lineResults: results, totals };
|
||||
});
|
||||
|
||||
// Sort shots by shotCode (natural/numeric order)
|
||||
shotData.sort((a, b) =>
|
||||
a.shot.shotCode.localeCompare(b.shot.shotCode, undefined, { numeric: true, sensitivity: "base" })
|
||||
);
|
||||
|
||||
// Group shots by episode (episodic) or shotGroup (standard)
|
||||
const jobType = job.jobType ?? "STANDARD";
|
||||
type ShotEntry = (typeof shotData)[number];
|
||||
const shotGroupMap = new Map<string, ShotEntry[]>();
|
||||
shotData.forEach(entry => {
|
||||
const key = jobType === "EPISODIC"
|
||||
? ((entry.shot as any).episode || "Ungrouped")
|
||||
: ((entry.shot as any).shotGroup || "Ungrouped");
|
||||
if (!shotGroupMap.has(key)) shotGroupMap.set(key, []);
|
||||
shotGroupMap.get(key)!.push(entry);
|
||||
});
|
||||
// Sort: named groups first, Ungrouped last
|
||||
const shotGroups: { label: string; entries: ShotEntry[] }[] = [];
|
||||
shotGroupMap.forEach((entries, label) => {
|
||||
if (label !== "Ungrouped") shotGroups.push({ label, entries });
|
||||
});
|
||||
shotGroups.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const ungrouped = shotGroupMap.get("Ungrouped");
|
||||
if (ungrouped?.length) shotGroups.push({ label: "Ungrouped", entries: ungrouped });
|
||||
const hasGroups = shotGroups.length > 1 || (shotGroups.length === 1 && shotGroups[0].label !== "Ungrouped");
|
||||
|
||||
const assetData = 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())
|
||||
);
|
||||
const totals = sumLineItems(results);
|
||||
return { asset, lineResults: results, totals };
|
||||
});
|
||||
|
||||
const allShotTotals = sumLineItems(shotData.map((s) => s.totals));
|
||||
const allAssetTotals = sumLineItems(assetData.map((a) => a.totals));
|
||||
|
||||
// Build supervision data
|
||||
const supervisionData = (job.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())
|
||||
);
|
||||
const totals = sumLineItems(results);
|
||||
return { sv, lineResults: results, totals };
|
||||
});
|
||||
const allSupervisionTotals = sumLineItems(supervisionData.map((s: any) => s.totals));
|
||||
|
||||
// Build previs data
|
||||
const previsData = (job.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())
|
||||
);
|
||||
const totals = sumLineItems(results);
|
||||
return { pv, lineResults: results, totals };
|
||||
});
|
||||
const allPrevisTotal = sumLineItems(previsData.map((p: any) => p.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);
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: "#71717a", SENT: "#60a5fa", APPROVED: "#4ade80", REJECTED: "#f87171", ARCHIVED: "#52525b",
|
||||
};
|
||||
const statusColor = statusColors[job.status] ?? "#71717a";
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Quote — ${job.projectCode}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fff; color: #111; font-size: 14px; line-height: 1.5; }
|
||||
.page { max-width: 1200px; margin: 0 auto; padding: 48px; }
|
||||
@media print { .page-break { page-break-before: always; } body { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
|
||||
/* Header */
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
|
||||
.logo { font-size: 28px; font-weight: 900; color: #111; letter-spacing: -1px; }
|
||||
.logo span { color: #f59e0b; }
|
||||
.studio-info { font-size: 12px; color: #555; line-height: 1.6; margin-top: 6px; }
|
||||
/* Doc title */
|
||||
.doc-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 2px solid #f0f0f0; }
|
||||
.doc-number { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; }
|
||||
.status-badge { padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; color: ${statusColor}; border: 1.5px solid ${statusColor}; background: ${statusColor}1a; }
|
||||
/* Parties */
|
||||
.parties { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 32px; }
|
||||
.section-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #aaa; margin-bottom: 8px; }
|
||||
.party-name { font-weight: 700; font-size: 15px; color: #111; }
|
||||
.party-detail { font-size: 13px; color: #555; margin-top: 2px; }
|
||||
/* Section */
|
||||
.section { margin-bottom: 24px; }
|
||||
.section-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #aaa; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid #f0f0f0; }
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; padding: 8px 12px; text-align: left; border-bottom: 2px solid #f0f0f0; }
|
||||
th.right, td.right { text-align: right; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid #f5f5f5; font-size: 13px; color: #333; vertical-align: top; }
|
||||
.summary-row td { background: #fffbeb; font-weight: 600; }
|
||||
/* Financials */
|
||||
.financials { background: #f9f9f9; border-radius: 12px; padding: 24px; margin-bottom: 32px; }
|
||||
.fin-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
|
||||
.fin-row.total { border-top: 2px solid #e5e5e5; margin-top: 8px; padding-top: 12px; font-weight: 800; font-size: 18px; }
|
||||
.fin-label { color: #555; }
|
||||
.fin-value { font-weight: 900; }
|
||||
.fin-row.total .fin-value { color: #f59e0b; }
|
||||
/* Terms / footer */
|
||||
.terms { padding: 20px; background: #f9f9f9; border-radius: 8px; font-size: 12px; color: #555; line-height: 1.6; margin-bottom: 24px; }
|
||||
.footer { border-top: 1px solid #f0f0f0; padding-top: 24px; text-align: center; font-size: 11px; color: #bbb; margin-top: 40px; }
|
||||
/* Appendix blocks */
|
||||
.shot-block { margin-bottom: 20px; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
|
||||
.shot-header { background: #1a1a1a; color: #fff; padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.shot-header h3 { font-size: 13px; font-weight: 700; }
|
||||
.shot-total { color: #f59e0b; font-size: 13px; font-weight: 700; font-family: monospace; }
|
||||
.complexity-badge { font-size: 10px; color: #f59e0b; margin-left: 8px; }
|
||||
.group-banner { margin-top: 20px; margin-bottom: 4px; padding: 10px 14px; background: #1a1a2e; border-radius: 6px; display: flex; justify-content: space-between; align-items: center; break-inside: avoid; }
|
||||
.group-banner span { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #f59e0b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${job.customHeaderHtml ?? ""}
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="studio-info">
|
||||
${studio?.studioName ? `<div style="font-size:18px;margin-top:4px;font-weight:900;">${studio.studioName}</div>` : ""}
|
||||
${studio?.studioAddress ? `<div style="white-space:pre-line;">${studio.studioAddress}</div>` : ""}
|
||||
${studio?.studioEmail ? `<div>${studio.studioEmail}</div>` : ""}
|
||||
${studio?.studioPhone ? `<div>${studio.studioPhone}</div>` : ""}
|
||||
${studio?.vatNumber ? `<div>VAT: ${studio.vatNumber}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Doc Title -->
|
||||
<div class="doc-title">
|
||||
<div>
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;margin-bottom:4px;">Quote</div>
|
||||
<div class="doc-number">TT_${job.projectCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parties -->
|
||||
<div class="parties">
|
||||
<div>
|
||||
<div class="section-label">Prepared For</div>
|
||||
<div class="party-name">${job.client.companyName}</div>
|
||||
${job.client.contactPerson ? `<div class="party-detail">${job.client.contactPerson}</div>` : ""}
|
||||
${job.client.email ? `<div class="party-detail">${job.client.email}</div>` : ""}
|
||||
${job.client.phone ? `<div class="party-detail">${job.client.phone}</div>` : ""}
|
||||
${job.client.billingAddress ? `<div class="party-detail" style="margin-top:6px;white-space:pre-line;">${job.client.billingAddress}</div>` : ""}
|
||||
${job.client.vatNumber ? `<div class="party-detail" style="margin-top:6px;font-size:12px;color:#aaa;">VAT: ${job.client.vatNumber}</div>` : ""}
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-label">Project Details</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 16px;font-size:13px;">
|
||||
<span style="color:#aaa;">Project</span><span style="font-weight:600;">${job.projectTitle}</span>
|
||||
<span style="color:#aaa;">Issue Date</span><span style="font-weight:600;">${format(new Date(job.issueDate), "d MMM yyyy")}</span>
|
||||
${job.expiryDate ? `<span style="color:#aaa;">Valid Until</span><span style="font-weight:600;">${format(new Date(job.expiryDate), "d MMM yyyy")}</span>` : ""}
|
||||
<span style="color:#aaa;">Currency</span><span style="font-weight:600;">${currency}</span>
|
||||
<span style="color:#aaa;">Type</span><span style="font-weight:600;">${jobType === "EPISODIC" ? "Episodic" : "Standard"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${job.description ? `<div class="section"><div class="section-title">Project Description</div><p style="font-size:13px;line-height:1.7;color:#444;">${job.description}</p></div>` : ""}
|
||||
|
||||
<!-- Quote Summary -->
|
||||
<div class="section" style="margin-bottom:32px;">
|
||||
<div class="section-title">Quote Summary</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px;">
|
||||
${shotData.length > 0 ? `
|
||||
<div style="background:#f9f9f9;border-radius:10px;padding:16px 20px;display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:4px;">VFX Shots</div>
|
||||
<div style="font-size:22px;font-weight:900;color:#111;">${shotData.length}<span style="font-size:13px;font-weight:500;color:#888;margin-left:4px;">shot${shotData.length !== 1 ? "s" : ""}</span></div>
|
||||
${hasGroups ? `<div style="font-size:11px;color:#aaa;margin-top:2px;">${shotGroups.length} group${shotGroups.length !== 1 ? "s" : ""}</div>` : ""}
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:10px;color:#aaa;margin-bottom:2px;">Subtotal</div>
|
||||
<div style="font-size:16px;font-weight:800;font-family:'Courier New',monospace;">${fmt(allShotTotals.total)}</div>
|
||||
</div>
|
||||
</div>` : ""}
|
||||
${assetData.length > 0 ? `
|
||||
<div style="background:#f9f9f9;border-radius:10px;padding:16px 20px;display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:4px;">VFX Assets</div>
|
||||
<div style="font-size:22px;font-weight:900;color:#111;">${assetData.length}<span style="font-size:13px;font-weight:500;color:#888;margin-left:4px;">asset${assetData.length !== 1 ? "s" : ""}</span></div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:10px;color:#aaa;margin-bottom:2px;">Subtotal</div>
|
||||
<div style="font-size:16px;font-weight:800;font-family:'Courier New',monospace;">${fmt(allAssetTotals.total)}</div>
|
||||
</div>
|
||||
</div>` : ""}
|
||||
${supervisionData.length > 0 ? `
|
||||
<div style="background:#f9f9f9;border-radius:10px;padding:16px 20px;display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:4px;">On-Set Supervision</div>
|
||||
<div style="font-size:22px;font-weight:900;color:#111;">${supervisionData.length}<span style="font-size:13px;font-weight:500;color:#888;margin-left:4px;">block${supervisionData.length !== 1 ? "s" : ""}</span></div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:10px;color:#aaa;margin-bottom:2px;">Subtotal</div>
|
||||
<div style="font-size:16px;font-weight:800;font-family:'Courier New',monospace;">${fmt(allSupervisionTotals.total)}</div>
|
||||
</div>
|
||||
</div>` : ""}
|
||||
${previsData.length > 0 ? `
|
||||
<div style="background:#f9f9f9;border-radius:10px;padding:16px 20px;display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:4px;">Previs</div>
|
||||
<div style="font-size:22px;font-weight:900;color:#111;">${previsData.length}<span style="font-size:13px;font-weight:500;color:#888;margin-left:4px;">sequence${previsData.length !== 1 ? "s" : ""}</span></div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:10px;color:#aaa;margin-bottom:2px;">Subtotal</div>
|
||||
<div style="font-size:16px;font-weight:800;font-family:'Courier New',monospace;">${fmt(allPrevisTotal.total)}</div>
|
||||
</div>
|
||||
</div>` : ""}
|
||||
</div>
|
||||
<div style="background:#1a1a1a;border-radius:10px;padding:16px 24px;color:#fff;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:32px;flex-wrap:wrap;">
|
||||
<div style="display:flex;gap:32px;flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Line Items Total</div>
|
||||
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">${fmt(lineSubtotal)}</div>
|
||||
</div>
|
||||
${Number(job.markupPercent) > 0 ? `
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Markup (${job.markupPercent}%)</div>
|
||||
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">+${fmt(jobAdj.markupAmount)}</div>
|
||||
</div>` : ""}
|
||||
${Number(job.discountPercent) > 0 ? `
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Discount (${job.discountPercent}%)</div>
|
||||
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;color:#f87171;">-${fmt(jobAdj.discountAmount)}</div>
|
||||
</div>` : ""}
|
||||
${Number(job.taxPercent) > 0 ? `
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Tax / VAT (${job.taxPercent}%)</div>
|
||||
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">+${fmt(jobAdj.taxAmount)}</div>
|
||||
</div>` : ""}
|
||||
${productionFeePct.gt(0) ? `
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Production Fee</div>
|
||||
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">+${fmt(productionFeeAmount)}</div>
|
||||
</div>` : ""}
|
||||
${pullManagementFeePct.gt(0) ? `
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Pull Management Fee</div>
|
||||
<div style="font-size:15px;font-weight:700;font-family:'Courier New',monospace;">+${fmt(pullManagementFeeAmount)}</div>
|
||||
</div>` : ""}
|
||||
</div>
|
||||
<div style="text-align:right;border-left:1px solid #333;padding-left:32px;">
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#666;margin-bottom:4px;">Grand Total</div>
|
||||
<div style="font-size:26px;font-weight:900;color:#f59e0b;font-family:'Courier New',monospace;">${fmt(grandTotalWithFees)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shots Summary -->
|
||||
${shotData.length > 0 ? `
|
||||
<div class="section">
|
||||
<div class="section-title">VFX Shots</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>${hasGroups ? (jobType === "EPISODIC" ? "Episode / Shot Code" : "Group / Shot Code") : "Shot Code"}</th>
|
||||
<th>Description</th>
|
||||
<th class="right">Tasks</th>
|
||||
<th class="right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${hasGroups
|
||||
? shotGroups.map(({ label, entries }) => {
|
||||
const groupTotal = sumLineItems(entries.map(e => e.totals)).total;
|
||||
return `<tr style="background:#f3f4f6;">
|
||||
<td colspan="2" style="font-weight:700;font-size:13px;padding:8px 12px;border-bottom:1px solid #e5e7eb;">${label}<span style="font-weight:400;color:#888;margin-left:8px;font-size:12px;">${entries.length} shot${entries.length !== 1 ? "s" : ""}</span></td>
|
||||
<td class="right" style="font-size:12px;color:#888;border-bottom:1px solid #e5e7eb;"></td>
|
||||
<td class="right" style="font-weight:800;border-bottom:1px solid #e5e7eb;">${fmt(groupTotal)}</td>
|
||||
</tr>
|
||||
${entries.map(({ shot, totals }) => `
|
||||
<tr>
|
||||
<td style="padding-left:20px;"><strong style="font-size:12px;font-family:'Courier New',monospace;">${shot.shotCode}</strong></td>
|
||||
<td style="color:#666;font-size:12px;">${(shot as any).description ?? ""}</td>
|
||||
<td class="right" style="font-size:12px;color:#888;">${shot.lineItems.length}</td>
|
||||
<td class="right" style="font-family:'Courier New',monospace;">${fmt(totals.total)}</td>
|
||||
</tr>`).join("")}`;
|
||||
}).join("")
|
||||
: shotData.map(({ shot, totals }) => `
|
||||
<tr>
|
||||
<td><strong>${shot.shotCode}</strong></td>
|
||||
<td style="color:#666;">${(shot as any).description ?? ""}</td>
|
||||
<td class="right">${shot.lineItems.length}</td>
|
||||
<td class="right" style="font-weight:800;">${fmt(totals.total)}</td>
|
||||
</tr>`).join("")
|
||||
}
|
||||
<tr class="summary-row">
|
||||
<td colspan="3">Shots Subtotal</td>
|
||||
<td class="right" style="font-family:'Courier New',monospace;">${fmt(allShotTotals.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : ""}
|
||||
|
||||
<!-- Assets Summary -->
|
||||
${assetData.length > 0 ? `
|
||||
<div class="section">
|
||||
<div class="section-title">VFX Assets</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Asset</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th class="right">Tasks</th>
|
||||
<th class="right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${assetData.map(({ asset, totals }) => `
|
||||
<tr>
|
||||
<td><strong>${asset.assetName}</strong></td>
|
||||
<td style="color:#666;">${asset.assetType ?? ""}</td>
|
||||
<td style="color:#666;">${asset.description ?? ""}</td>
|
||||
<td class="right">${asset.lineItems.length}</td>
|
||||
<td class="right" style="font-family:'Courier New',monospace;font-weight:600;">${fmt(totals.total)}</td>
|
||||
</tr>`).join("")}
|
||||
<tr class="summary-row">
|
||||
<td colspan="4">Assets Subtotal</td>
|
||||
<td class="right" style="font-family:'Courier New',monospace;">${fmt(allAssetTotals.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : ""}
|
||||
|
||||
<!-- Supervision Summary -->
|
||||
${supervisionData.length > 0 ? `
|
||||
<div class="section">
|
||||
<div class="section-title">On-Set Supervision</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Supervision</th>
|
||||
<th>Location</th>
|
||||
<th>Dates</th>
|
||||
<th class="right">Days</th>
|
||||
<th class="right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${supervisionData.map(({ sv, totals }: any) => {
|
||||
const totalDays = sv.numShootDays + sv.prepDays + sv.travelDays + sv.wrapDays;
|
||||
return `<tr>
|
||||
<td><strong>${sv.supervisionTitle}</strong>${sv.description ? `<br><span style="color:#666;font-size:12px;">${sv.description}</span>` : ""}</td>
|
||||
<td style="color:#666;">${sv.location ?? ""}</td>
|
||||
<td style="color:#666;">${sv.shootDates ?? ""}</td>
|
||||
<td class="right">${totalDays > 0 ? totalDays : "—"}</td>
|
||||
<td class="right" style="font-family:'Courier New',monospace;font-weight:600;">${fmt(totals.total)}</td>
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
<tr class="summary-row">
|
||||
<td colspan="4">Supervision Subtotal</td>
|
||||
<td class="right" style="font-family:'Courier New',monospace;">${fmt(allSupervisionTotals.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : ""}
|
||||
|
||||
<!-- Previs Summary -->
|
||||
${previsData.length > 0 ? `
|
||||
<div class="section">
|
||||
<div class="section-title">Previs</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Sequence</th>
|
||||
<th>Type</th>
|
||||
<th>Stage</th>
|
||||
<th class="right">Duration</th>
|
||||
<th class="right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${previsData.map(({ pv, totals }: any) => `
|
||||
<tr>
|
||||
<td><strong>${pv.sequenceName}</strong>${pv.previsCode ? `<br><span style="color:#aaa;font-size:11px;">${pv.previsCode}</span>` : ""}
|
||||
${pv.description ? `<br><span style="color:#666;font-size:12px;">${pv.description}</span>` : ""}
|
||||
</td>
|
||||
<td style="color:#666;">${pv.sequenceType}</td>
|
||||
<td><span style="padding:2px 8px;border-radius:4px;background:#ede9fe;color:#5b21b6;font-size:11px;font-weight:600;">${pv.deliveryStage}</span></td>
|
||||
<td class="right" style="color:#666;">${pv.duration ?? "—"}</td>
|
||||
<td class="right" style="font-family:'Courier New',monospace;font-weight:600;">${fmt(totals.total)}</td>
|
||||
</tr>`).join("")}
|
||||
<tr class="summary-row">
|
||||
<td colspan="4">Previs Subtotal</td>
|
||||
<td class="right" style="font-family:'Courier New',monospace;">${fmt(allPrevisTotal.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : ""}
|
||||
|
||||
<!-- Financials / Totals -->
|
||||
<div class="financials">
|
||||
<div class="fin-row"><span class="fin-label">Line Items Total</span><span class="fin-value">${fmt(lineSubtotal)}</span></div>
|
||||
${Number(job.markupPercent) > 0 ? `<div class="fin-row"><span class="fin-label">Markup (${job.markupPercent}%)</span><span class="fin-value">+${fmt(jobAdj.markupAmount)}</span></div>` : ""}
|
||||
${Number(job.discountPercent) > 0 ? `<div class="fin-row"><span class="fin-label">Discount (${job.discountPercent}%)</span><span class="fin-value" style="color:#dc2626;">-${fmt(jobAdj.discountAmount)}</span></div>` : ""}
|
||||
${Number(job.taxPercent) > 0 ? `<div class="fin-row"><span class="fin-label">Tax / VAT (${job.taxPercent}%)</span><span class="fin-value">+${fmt(jobAdj.taxAmount)}</span></div>` : ""}
|
||||
${productionFeePct.gt(0) ? `<div class="fin-row"><span class="fin-label">Production Fee</span><span class="fin-value">+${fmt(productionFeeAmount)}</span></div>` : ""}
|
||||
${pullManagementFeePct.gt(0) ? `<div class="fin-row"><span class="fin-label">Pull Management Fee</span><span class="fin-value">+${fmt(pullManagementFeeAmount)}</span></div>` : ""}
|
||||
<div class="fin-row total"><span>Grand Total</span><span class="fin-value">${fmt(grandTotalWithFees)}</span></div>
|
||||
</div>
|
||||
|
||||
${job.termsAndConditions ? `<div class="terms"><strong style="display:block;margin-bottom:8px;">Terms & Conditions</strong>${job.termsAndConditions}</div>` : ""}
|
||||
|
||||
<div class="footer">
|
||||
${studio?.invoiceFooter ? `<p style="margin-bottom:6px;">${studio.invoiceFooter}</p>` : ""}
|
||||
|
||||
</div>
|
||||
|
||||
${job.customFooterHtml ?? ""}
|
||||
</div>
|
||||
|
||||
<!-- APPENDIX -->
|
||||
<div class="page page-break">
|
||||
<div class="header">
|
||||
<div style="text-align:right;">
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;margin-bottom:4px;">Appendix</div>
|
||||
<div class="doc-number" style="font-size:20px;">Detailed Breakdown</div>
|
||||
<div style="font-size:12px;color:#666;margin-top:2px;">${job.projectCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-bottom:2px solid #f0f0f0;margin-bottom:32px;"></div>
|
||||
|
||||
${hasGroups
|
||||
? shotGroups.map(({ label, entries }) => {
|
||||
const groupTotal = sumLineItems(entries.map(e => e.totals)).total;
|
||||
return `
|
||||
<div class="group-banner">
|
||||
<span>${label}</span>
|
||||
<span>${fmt(groupTotal)}</span>
|
||||
</div>
|
||||
${entries.map(({ shot, totals }) => `
|
||||
<div class="shot-block">
|
||||
<div class="shot-header">
|
||||
<h3>${shot.shotCode}${Number(shot.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(shot.complexityMultiplier).toFixed(2)}</span>` : ""}
|
||||
${shot.description ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${shot.description}</span>` : ""}
|
||||
</h3>
|
||||
<span class="shot-total">${fmt(totals.total)}</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Task</th><th>Role</th>
|
||||
<th class="right">Hours</th><th class="right">Day Rate</th>
|
||||
<th class="right">Hrs/Day</th><th class="right">Artists</th>
|
||||
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${shot.lineItems.map((li) => {
|
||||
const c = 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 `<tr>
|
||||
<td>${li.taskName}</td>
|
||||
<td style="color:#666;">${li.artistRole ?? "—"}</td>
|
||||
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
|
||||
<td class="right">${Number(li.hoursPerDay)}</td>
|
||||
<td class="right">${Number(li.numArtists)}</td>
|
||||
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${fmt(c.subtotal)}</td>
|
||||
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
<tr class="summary-row">
|
||||
<td colspan="7">Shot Total</td>
|
||||
<td class="right" style="">${fmt(totals.subtotal)}</td>
|
||||
<td class="right" style="">${fmt(totals.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${shot.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${shot.notes}</em></div>` : ""}
|
||||
</div>`).join("")}`;
|
||||
}).join("")
|
||||
: shotData.map(({ shot, totals }) => `
|
||||
<div class="shot-block">
|
||||
<div class="shot-header">
|
||||
<h3>${shot.shotCode}${Number(shot.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(shot.complexityMultiplier).toFixed(2)}</span>` : ""}
|
||||
${shot.description ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${shot.description}</span>` : ""}
|
||||
</h3>
|
||||
<span class="shot-total">${fmt(totals.total)}</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Task</th><th>Role</th>
|
||||
<th class="right">Hours</th><th class="right">Day Rate</th>
|
||||
<th class="right">Hrs/Day</th><th class="right">Artists</th>
|
||||
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${shot.lineItems.map((li) => {
|
||||
const c = 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 `<tr>
|
||||
<td>${li.taskName}</td>
|
||||
<td style="color:#666;">${li.artistRole ?? "—"}</td>
|
||||
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
|
||||
<td class="right">${Number(li.hoursPerDay)}</td>
|
||||
<td class="right">${Number(li.numArtists)}</td>
|
||||
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${fmt(c.subtotal)}</td>
|
||||
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
<tr class="summary-row">
|
||||
<td colspan="7">Shot Total</td>
|
||||
<td class="right" style="">${fmt(totals.subtotal)}</td>
|
||||
<td class="right" style="">${fmt(totals.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${shot.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${shot.notes}</em></div>` : ""}
|
||||
</div>`).join("")
|
||||
}
|
||||
|
||||
${assetData.map(({ asset, totals }) => `
|
||||
<div class="shot-block">
|
||||
<div class="shot-header">
|
||||
<h3>${asset.assetName}${asset.assetType ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${asset.assetType}</span>` : ""}
|
||||
${Number(asset.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(asset.complexityMultiplier).toFixed(2)}</span>` : ""}
|
||||
</h3>
|
||||
<span class="shot-total">${fmt(totals.total)}</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Task</th><th>Role</th>
|
||||
<th class="right">Hours</th><th class="right">Day Rate</th>
|
||||
<th class="right">Hrs/Day</th><th class="right">Artists</th>
|
||||
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${asset.lineItems.map((li: any) => {
|
||||
const c = 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 `<tr>
|
||||
<td>${li.taskName}</td>
|
||||
<td style="color:#666;">${li.artistRole ?? "—"}</td>
|
||||
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
|
||||
<td class="right">${Number(li.hoursPerDay)}</td>
|
||||
<td class="right">${Number(li.numArtists)}</td>
|
||||
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${fmt(c.subtotal)}</td>
|
||||
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
<tr class="summary-row">
|
||||
<td colspan="7">Asset Total</td>
|
||||
<td class="right" style="">${fmt(totals.subtotal)}</td>
|
||||
<td class="right" style="">${fmt(totals.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${asset.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${asset.notes}</em></div>` : ""}
|
||||
</div>`).join("")}
|
||||
|
||||
${supervisionData.length > 0 ? `
|
||||
<div class="group-banner">
|
||||
<span>On-Set Supervision</span>
|
||||
<span>${fmt(allSupervisionTotals.total)}</span>
|
||||
</div>
|
||||
${supervisionData.map(({ sv, totals }: any) => {
|
||||
const totalDays = sv.numShootDays + sv.prepDays + sv.travelDays + sv.wrapDays;
|
||||
const dayBreakdown = [
|
||||
sv.numShootDays > 0 ? `${sv.numShootDays} shoot` : "",
|
||||
sv.prepDays > 0 ? `${sv.prepDays} prep` : "",
|
||||
sv.travelDays > 0 ? `${sv.travelDays} travel` : "",
|
||||
sv.wrapDays > 0 ? `${sv.wrapDays} wrap` : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
return `
|
||||
<div class="shot-block">
|
||||
<div class="shot-header">
|
||||
<h3>${sv.supervisionTitle}${Number(sv.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(sv.complexityMultiplier).toFixed(2)}</span>` : ""}
|
||||
${sv.location ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${sv.location}</span>` : ""}
|
||||
${sv.shootDates ? `<span style="font-weight:400;font-size:11px;color:#888;margin-left:8px;">${sv.shootDates}</span>` : ""}
|
||||
</h3>
|
||||
<span class="shot-total">${fmt(totals.total)}</span>
|
||||
</div>
|
||||
${totalDays > 0 ? `<div style="padding:6px 14px;background:#111;font-size:11px;color:#aaa;">${dayBreakdown} · ${totalDays} days total</div>` : ""}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Task / Service</th><th>Role</th>
|
||||
<th class="right">Hours</th><th class="right">Day Rate</th>
|
||||
<th class="right">Hrs/Day</th><th class="right">People</th>
|
||||
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${sv.lineItems.map((li: any) => {
|
||||
const c = 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 `<tr>
|
||||
<td>${li.taskName}</td>
|
||||
<td style="color:#666;">${li.artistRole ?? "—"}</td>
|
||||
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
|
||||
<td class="right">${Number(li.hoursPerDay)}</td>
|
||||
<td class="right">${Number(li.numArtists)}</td>
|
||||
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${fmt(c.subtotal)}</td>
|
||||
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
<tr class="summary-row">
|
||||
<td colspan="7">Supervision Total</td>
|
||||
<td class="right" style="">${fmt(totals.subtotal)}</td>
|
||||
<td class="right" style="">${fmt(totals.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${sv.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${sv.notes}</em></div>` : ""}
|
||||
</div>`;
|
||||
}).join("")}` : ""}
|
||||
|
||||
${previsData.length > 0 ? `
|
||||
<div class="group-banner" style="background:#1e1b4b;">
|
||||
<span style="color:#a5b4fc;">Previs</span>
|
||||
<span style="color:#a5b4fc;">${fmt(allPrevisTotal.total)}</span>
|
||||
</div>
|
||||
${previsData.map(({ pv, totals }: any) => `
|
||||
<div class="shot-block">
|
||||
<div class="shot-header">
|
||||
<h3>${pv.sequenceName}${pv.previsCode ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${pv.previsCode}</span>` : ""}
|
||||
${Number(pv.complexityMultiplier) !== 1 ? `<span class="complexity-badge">×${Number(pv.complexityMultiplier).toFixed(2)}</span>` : ""}
|
||||
<span style="font-weight:400;font-size:11px;color:#a5b4fc;margin-left:8px;">${pv.sequenceType}</span>
|
||||
<span style="font-weight:400;font-size:11px;color:#888;margin-left:8px;padding:2px 6px;background:#1e1b4b;border-radius:3px;">${pv.deliveryStage}</span>
|
||||
${pv.duration ? `<span style="font-weight:400;font-size:11px;color:#aaa;margin-left:8px;">${pv.duration}</span>` : ""}
|
||||
</h3>
|
||||
<span class="shot-total">${fmt(totals.total)}</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Task / Service</th><th>Role</th>
|
||||
<th class="right">Hours</th><th class="right">Day Rate</th>
|
||||
<th class="right">Hrs/Day</th><th class="right">People</th>
|
||||
<th class="right">Days</th><th class="right">Subtotal</th><th class="right">Total</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${pv.lineItems.map((li: any) => {
|
||||
const c = 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 `<tr>
|
||||
<td>${li.taskName}</td>
|
||||
<td style="color:#666;">${li.artistRole ?? "—"}</td>
|
||||
<td class="right" style="">${Number(li.quantityHours) > 0 ? Number(li.quantityHours).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${Number(li.dailyRate) > 0 ? fmt(new Decimal(li.dailyRate.toString())) : "—"}</td>
|
||||
<td class="right">${Number(li.hoursPerDay)}</td>
|
||||
<td class="right">${Number(li.numArtists)}</td>
|
||||
<td class="right">${Number(li.numDays) > 0 ? Number(li.numDays).toFixed(1) : "—"}</td>
|
||||
<td class="right" style="">${fmt(c.subtotal)}</td>
|
||||
<td class="right" style="font-weight:700;">${fmt(c.total)}</td>
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
<tr style="background:#1e1b4b;color:#c4b5fd;font-weight:700;">
|
||||
<td colspan="7">Previs Total</td>
|
||||
<td class="right" style="">${fmt(totals.subtotal)}</td>
|
||||
<td class="right" style="">${fmt(totals.total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${pv.notes ? `<div style="padding:10px 14px;background:#fafafa;font-size:12px;color:#666;border-top:1px solid #f0f0f0;"><em>${pv.notes}</em></div>` : ""}
|
||||
</div>`).join("")}` : ""}
|
||||
|
||||
<div class="footer">
|
||||
<p>${job.projectCode} · Appendix</p>
|
||||
<p style="margin-top:4px;">Generated ${format(new Date(), "d MMM yyyy")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatCurrency } from "@/lib/calculations";
|
||||
import { format } from "date-fns";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const invoice = await prisma.invoice.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
client: true,
|
||||
job: true,
|
||||
milestone: true,
|
||||
payments: { orderBy: { paymentDate: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch studio settings for branding
|
||||
const studio = await prisma.studioSettings.findFirst();
|
||||
|
||||
const fmt = (v: number | string | { toNumber?: () => number }) => formatCurrency(typeof v === 'object' && v && 'toNumber' in v ? v.toNumber!() : Number(v), invoice.currency);
|
||||
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 statusColors: Record<string, string> = {
|
||||
DRAFT: "#71717a",
|
||||
SENT: "#60a5fa",
|
||||
VIEWED: "#a78bfa",
|
||||
PARTIALLY_PAID: "#f97316",
|
||||
PAID: "#4ade80",
|
||||
OVERDUE: "#f87171",
|
||||
CANCELLED: "#52525b",
|
||||
};
|
||||
const statusColor = statusColors[invoice.status] ?? "#71717a";
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Invoice ${invoice.invoiceNumber}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fff; color: #111; font-size: 14px; line-height: 1.5; }
|
||||
.page { max-width: 1200px; margin: 0 auto; padding: 48px 48px; }
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
|
||||
.logo { font-size: 28px; font-weight: 900; color: #111; letter-spacing: -1px; }
|
||||
.logo span { color: #f59e0b; }
|
||||
.studio-info { text-align: right; font-size: 12px; color: #555; line-height: 1.6; }
|
||||
.invoice-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 2px solid #f0f0f0; }
|
||||
.invoice-number { font-size: 24px; font-weight: 800; letter-spacing: -0.5px; }
|
||||
.status-badge { padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; color: ${statusColor}; border: 1.5px solid ${statusColor}; background: ${statusColor}1a; }
|
||||
.parties { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 32px; }
|
||||
.section-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #aaa; margin-bottom: 8px; }
|
||||
.party-name { font-weight: 700; font-size: 15px; color: #111; }
|
||||
.party-detail { font-size: 13px; color: #555; margin-top: 2px; }
|
||||
.meta { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-bottom: 32px; }
|
||||
.meta-item { }
|
||||
.meta-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #aaa; }
|
||||
.meta-value { font-size: 14px; color: #111; font-weight: 600; margin-top: 2px; }
|
||||
.financials { background: #f9f9f9; border-radius: 12px; padding: 24px; margin-bottom: 32px; }
|
||||
.fin-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
|
||||
.fin-row.total { border-top: 2px solid #e5e5e5; margin-top: 8px; padding-top: 12px; font-weight: 800; font-size: 18px; }
|
||||
.fin-label { color: #555; }
|
||||
.fin-value { font-weight: 900; }
|
||||
.fin-row.total .fin-value { color: #f59e0b; }
|
||||
.payment-status { background: #f0fdf4; border: 1.5px solid #bbf7d0; border-radius: 12px; padding: 20px; margin-bottom: 32px; }
|
||||
.progress-bar { height: 8px; background: #d1fae5; border-radius: 4px; overflow: hidden; margin: 8px 0; }
|
||||
.progress-fill { height: 100%; background: #22c55e; border-radius: 4px; width: ${paidPercent.toFixed(1)}%; }
|
||||
.progress-labels { display: flex; justify-content: space-between; font-size: 12px; color: #555; }
|
||||
.payment-amounts { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-top: 12px; }
|
||||
.pay-amount { }
|
||||
.pay-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: #aaa; font-weight: 600; }
|
||||
.pay-value { font-size: 16px; font-weight: 800; font-family: 'Courier New', monospace; margin-top: 2px; }
|
||||
.payments-table { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
|
||||
.payments-table th { text-align: left; font-size: 10px; text-transform: uppercase; letter-spacing: 1px; color: #aaa; font-weight: 700; padding: 8px 12px; border-bottom: 2px solid #f0f0f0; }
|
||||
.payments-table td { padding: 10px 12px; font-size: 13px; border-bottom: 1px solid #f5f5f5; }
|
||||
.banking { background: #fffbeb; border: 1.5px solid #fde68a; border-radius: 12px; padding: 20px; margin-bottom: 32px; }
|
||||
.banking-content { font-weight: 800; font-size: 13px; color: #555; white-space: pre-line; }
|
||||
.notes { margin-bottom: 32px; }
|
||||
.notes-content { font-size: 13px; color: #555; white-space: pre-line; line-height: 1.6; }
|
||||
.footer { border-top: 1px solid #f0f0f0; padding-top: 24px; text-align: center; font-size: 11px; color: #bbb; }
|
||||
@media print { body { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div>
|
||||
${studio?.studioName ? `<div style="font-size:18px;margin-top:4px;font-weight:900;">${studio.studioName}</div>` : ""}
|
||||
${studio?.studioAddress ? `<div>${studio.studioAddress}</div>` : ""}
|
||||
${studio?.companyRegNumber ? `<div>REG: ${studio.companyRegNumber}</div>` : ""}
|
||||
${studio?.studioEmail ? `<div>${studio.studioEmail}</div>` : ""}
|
||||
${studio?.studioPhone ? `<div>${studio.studioPhone}</div>` : ""}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Invoice number + status -->
|
||||
<div class="invoice-title">
|
||||
<div>
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;margin-bottom:4px;">Tax Invoice</div>
|
||||
<div class="invoice-number">${invoice.invoiceNumber}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parties -->
|
||||
<div class="parties">
|
||||
<div>
|
||||
<div class="section-label">Bill To</div>
|
||||
<div class="party-name">${invoice.client.companyName}</div>
|
||||
${invoice.client.billingAddress ? `<div class="party-detail">${invoice.client.billingAddress.replace(/\n/g, "<br>")}</div>` : ""}
|
||||
${invoice.client.companyRegNumber ? `<div class="party-detail" style="margin-top:6px;font-size:12px;color:#aaa;">REG: ${invoice.client.companyRegNumber}</div>` : ""}
|
||||
${invoice.client.vatNumber ? `<div class="party-detail" style="margin-top:6px;font-size:12px;color:#aaa;">VAT: ${invoice.client.vatNumber}</div>` : ""}
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<div class="section-label">Invoice Details</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 16px;font-size:13px;">
|
||||
<span style="color:#aaa;">Issue Date</span><span style="font-weight:600;">${format(new Date(invoice.issueDate), "d MMM yyyy")}</span>
|
||||
${invoice.dueDate ? `<span style="color:#aaa;">Due Date</span><span style="font-weight:600;">${format(new Date(invoice.dueDate), "d MMM yyyy")}</span>` : ""}
|
||||
${invoice.paymentTerms ? `<span style="color:#aaa;">Terms</span><span style="font-weight:600;">${invoice.paymentTerms}</span>` : ""}
|
||||
<span style="color:#aaa;">Project</span><span style="font-weight:600;">${invoice.job.projectCode}</span>
|
||||
${invoice.milestone ? `<span style="color:#aaa;">Milestone</span><span style="font-weight:600;">${invoice.milestone.title}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services / Line Items -->
|
||||
<div style="margin-bottom:32px;">
|
||||
<div class="section-label" style="margin-bottom:12px;">Services</div>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #f0f0f0;">
|
||||
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;width:32px;">#</th>
|
||||
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Description</th>
|
||||
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Period</th>
|
||||
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">%</th>
|
||||
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #f5f5f5;">
|
||||
<td style="padding:12px;color:#aaa;vertical-align:top;">1</td>
|
||||
<td style="padding:12px;vertical-align:top;">
|
||||
<div style="font-weight:700;font-size:14px;color:#111;">${invoice.job.projectCode} — ${invoice.milestone?.title ?? "Professional VFX Services"}</div>
|
||||
${invoice.milestone?.description ? `<div style="font-size:12px;color:#777;margin-top:2px;">${invoice.milestone.description}</div>` : ""}
|
||||
<div style="font-size:12px;color:#aaa;margin-top:2px;">${invoice.job.projectTitle}</div>
|
||||
</td>
|
||||
<td style="padding:12px;color:#555;vertical-align:top;white-space:nowrap;">${invoice.milestone?.dueDate ? format(new Date(invoice.milestone.dueDate), "MMM yyyy") : format(new Date(invoice.issueDate), "MMM yyyy")}</td>
|
||||
<td style="padding:12px;text-align:right;color:#555;vertical-align:top;">${invoice.milestone ? `${Number(invoice.milestone.percentOfTotal).toFixed(0)}%` : "—"}</td>
|
||||
<td style="padding:12px;text-align:right;font-weight:700;font-size:15px;color:#111;vertical-align:top;">${fmt(invoice.subtotal)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Financial summary -->
|
||||
<div class="financials">
|
||||
<div class="fin-row">
|
||||
<span class="fin-label">Subtotal</span>
|
||||
<span class="fin-value">${fmt(invoice.subtotal)}</span>
|
||||
</div>
|
||||
<div class="fin-row">
|
||||
<span class="fin-label">Discount (${Number(invoice.taxPercent)}%)</span>
|
||||
<span class="fin-value">${fmt(invoice.taxAmount)}</span>
|
||||
</div>
|
||||
<div class="fin-row total">
|
||||
<span>Total</span>
|
||||
<span class="fin-value">${fmt(totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Payment history -->
|
||||
${invoice.payments.length > 0 ? `
|
||||
<div style="margin-bottom:32px;">
|
||||
<div class="section-label" style="margin-bottom:12px;">Payment History</div>
|
||||
<table class="payments-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Method</th>
|
||||
<th>Reference</th>
|
||||
<th style="text-align:right;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${invoice.payments.map(p => `
|
||||
<tr>
|
||||
<td>${format(new Date(p.paymentDate), "d MMM yyyy")}</td>
|
||||
<td>${p.paymentMethod.replace("_", " ")}</td>
|
||||
<td style="color:#777;">${p.reference ?? "—"}</td>
|
||||
<td style="text-align:right;font-weight:600;color:#22c55e;">${fmt(p.amount)}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : ""}
|
||||
|
||||
<!-- Banking details -->
|
||||
${invoice.bankingDetails ? `
|
||||
<div class="banking">
|
||||
<div class="section-label" style="margin-bottom:8px;">Banking Details</div>
|
||||
<div class="banking-content">${invoice.bankingDetails}</div>
|
||||
</div>` : ""}
|
||||
|
||||
<!-- Notes -->
|
||||
${invoice.notes ? `
|
||||
<div class="notes">
|
||||
<div class="section-label" style="margin-bottom:8px;">Notes</div>
|
||||
<div class="notes-content">${invoice.notes}</div>
|
||||
</div>` : ""}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
${studio?.invoiceFooter ? `<p style="margin-bottom:8px;">${studio.invoiceFooter}</p>` : ""}
|
||||
<p>Generated ${format(new Date(), "d MMM yyyy 'at' HH:mm")} · ${invoice.invoiceNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Disposition": `inline; filename="${invoice.invoiceNumber}.html"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { calcLineItem, sumLineItems, applyJobAdjustments, formatCurrency } from "@/lib/calculations";
|
||||
import { format } from "date-fns";
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ jobId: string }> }
|
||||
) {
|
||||
const { jobId } = await params;
|
||||
|
||||
const job = await prisma.job.findUnique({
|
||||
where: { id: jobId },
|
||||
include: {
|
||||
client: true,
|
||||
paymentSchedule: {
|
||||
include: {
|
||||
milestones: { orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
},
|
||||
shots: {
|
||||
include: { lineItems: true },
|
||||
},
|
||||
assets: {
|
||||
include: { lineItems: true },
|
||||
},
|
||||
supervisions: {
|
||||
include: { lineItems: true },
|
||||
},
|
||||
previses: {
|
||||
include: { lineItems: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "Job not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const studio = await prisma.studioSettings.findFirst();
|
||||
const currency = "ZAR";
|
||||
const fmt = (v: number) => formatCurrency(v, currency);
|
||||
|
||||
// Compute grand total from all line items (same as job detail page)
|
||||
const allResults = [
|
||||
...job.shots.flatMap(s => s.lineItems.map(li => calcLineItem(li as any, s.complexityMultiplier.toString()))),
|
||||
...job.assets.flatMap(a => a.lineItems.map(li => calcLineItem(li as any, a.complexityMultiplier.toString()))),
|
||||
...(job.supervisions ?? []).flatMap((sv: any) => sv.lineItems.map((li: any) => calcLineItem(li as any, sv.complexityMultiplier.toString()))),
|
||||
...(job.previses ?? []).flatMap((pv: any) => pv.lineItems.map((li: any) => calcLineItem(li as any, pv.complexityMultiplier.toString()))),
|
||||
];
|
||||
const sub = allResults.reduce((s, r) => s.add(r.subtotal), new Decimal(0));
|
||||
const { grandTotal: grandTotalDecimal } = applyJobAdjustments(
|
||||
sub,
|
||||
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 grandTotalWithFees = grandTotalDecimal
|
||||
.add(grandTotalDecimal.mul(productionFeePct.div(100)))
|
||||
.add(grandTotalDecimal.mul(pullManagementFeePct.div(100)));
|
||||
const jobTotal = grandTotalWithFees.toNumber();
|
||||
|
||||
const schedule = job.paymentSchedule;
|
||||
const milestones = schedule?.milestones ?? [];
|
||||
|
||||
const amountForMilestone = (m: { fixedAmount: any; percentOfTotal: any }) => {
|
||||
if (m.fixedAmount && Number(m.fixedAmount) > 0) return Number(m.fixedAmount);
|
||||
return (Number(m.percentOfTotal) / 100) * jobTotal;
|
||||
};
|
||||
|
||||
const totalPercent = milestones.reduce((s, m) => s + Number(m.percentOfTotal), 0);
|
||||
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);
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
PENDING: "#71717a",
|
||||
INVOICED: "#60a5fa",
|
||||
PARTIALLY_PAID: "#f97316",
|
||||
PAID: "#4ade80",
|
||||
OVERDUE: "#f87171",
|
||||
CANCELLED: "#52525b",
|
||||
};``
|
||||
const statusLabels: Record<string, string> = {
|
||||
PENDING: "Pending",
|
||||
INVOICED: "Invoiced",
|
||||
PARTIALLY_PAID: "Partial",
|
||||
PAID: "Paid",
|
||||
OVERDUE: "Overdue",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
const milestoneRows = milestones
|
||||
.map((m, i) => {
|
||||
const amount = amountForMilestone(m);
|
||||
const color = statusColors[m.status] ?? "#71717a";
|
||||
const label = statusLabels[m.status] ?? m.status;
|
||||
const isPaid = m.status === "PAID";
|
||||
return `
|
||||
<tr style="border-bottom:1px solid #f5f5f5;${isPaid ? "opacity:0.65;" : ""}">
|
||||
<td style="padding:12px;color:#aaa;vertical-align:top;font-size:13px;">${i + 1}</td>
|
||||
<td style="padding:12px;vertical-align:top;">
|
||||
<div style="font-weight:700;font-size:14px;color:#111;">${m.title}</div>
|
||||
${m.description ? `<div style="font-size:12px;color:#777;margin-top:2px;">${m.description}</div>` : ""}
|
||||
</td>
|
||||
<td style="padding:12px;text-align:right;color:#555;vertical-align:top;white-space:nowrap;font-size:13px;">
|
||||
${m.dueDate ? format(new Date(m.dueDate), "d MMM yyyy") : "—"}
|
||||
</td>
|
||||
<td style="padding:12px;text-align:right;color:#555;vertical-align:top;font-size:13px;">
|
||||
${Number(m.percentOfTotal).toFixed(1)}%
|
||||
</td>
|
||||
<td style="padding:12px;text-align:right;font-weight:700;font-size:14px;color:#111;vertical-align:top;">
|
||||
${fmt(amount)}
|
||||
</td>
|
||||
<td style="padding:12px;vertical-align:top;text-align:center;">
|
||||
<span style="padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;letter-spacing:0.5px;color:${color};border:1.5px solid ${color};background:${color}1a;">
|
||||
${label}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
${m.paymentReceivedDate ? `
|
||||
<tr style="border-bottom:1px solid #f5f5f5;background:#f0fdf4;">
|
||||
<td style="padding:4px 12px;" colspan="2"></td>
|
||||
<td style="padding:4px 12px;font-size:12px;color:#22c55e;text-align:right;" colspan="2">
|
||||
Payment received: ${format(new Date(m.paymentReceivedDate), "d MMM yyyy")}
|
||||
</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>` : ""}`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Payment Schedule — ${job.projectCode}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fff; color: #111; font-size: 14px; line-height: 1.5; }
|
||||
.page { max-width: 1200px; margin: 0 auto; padding: 48px; }
|
||||
@media print { body { print-color-adjust: exact; -webkit-print-color-adjust: exact; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:40px;">
|
||||
<div style="font-size:12px;color:#555;line-height:1.6;">
|
||||
${studio?.studioName ? `<div style="font-size:18px;font-weight:900;color:#111;margin-bottom:4px;">${studio.studioName}</div>` : ""}
|
||||
${studio?.studioAddress ? `<div style="white-space:pre-line;">${studio.studioAddress}</div>` : ""}
|
||||
${studio?.companyRegNumber ? `<div>REG: ${studio.companyRegNumber}</div>` : ""}
|
||||
${studio?.studioEmail ? `<div>${studio.studioEmail}</div>` : ""}
|
||||
${studio?.studioPhone ? `<div>${studio.studioPhone}</div>` : ""}
|
||||
${studio?.vatNumber ? `<div>VAT: ${studio.vatNumber}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document title -->
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:32px;padding-bottom:24px;border-bottom:2px solid #f0f0f0;">
|
||||
<div>
|
||||
<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;margin-bottom:4px;">Payment Schedule</div>
|
||||
<div style="font-size:24px;font-weight:800;letter-spacing:-0.5px;">${job.projectCode}</div>
|
||||
<div style="font-size:14px;color:#555;margin-top:4px;">${job.projectTitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parties & project details -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-bottom:32px;">
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:8px;">Prepared For</div>
|
||||
<div style="font-weight:700;font-size:15px;color:#111;">${job.client.companyName}</div>
|
||||
${job.client.contactPerson ? `<div style="font-size:13px;color:#555;margin-top:2px;">${job.client.contactPerson}</div>` : ""}
|
||||
${job.client.email ? `<div style="font-size:13px;color:#555;">${job.client.email}</div>` : ""}
|
||||
${job.client.phone ? `<div style="font-size:13px;color:#555;">${job.client.phone}</div>` : ""}
|
||||
${job.client.billingAddress ? `<div style="font-size:13px;color:#555;margin-top:6px;white-space:pre-line;">${job.client.billingAddress}</div>` : ""}
|
||||
${job.client.vatNumber ? `<div style="font-size:12px;color:#aaa;margin-top:6px;">VAT: ${job.client.vatNumber}</div>` : ""}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:8px;">Project Details</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 16px;font-size:13px;">
|
||||
<span style="color:#aaa;">Issue Date</span><span style="font-weight:600;">${format(new Date(), "d MMM yyyy")}</span>
|
||||
<span style="color:#aaa;">Currency</span><span style="font-weight:600;">${currency}</span>
|
||||
${job.startDate ? `<span style="color:#aaa;">Start Date</span><span style="font-weight:600;">${format(new Date(job.startDate), "d MMM yyyy")}</span>` : ""}
|
||||
${job.estimatedCompletionDate ? `<span style="color:#aaa;">Est. Completion</span><span style="font-weight:600;">${format(new Date(job.estimatedCompletionDate), "d MMM yyyy")}</span>` : ""}
|
||||
<span style="color:#aaa;">Milestones</span><span style="font-weight:600;">${milestones.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:32px;">
|
||||
${[
|
||||
{ label: "Project Total", value: fmt(jobTotal), color: "#111" },
|
||||
{ label: "Paid to Date", value: fmt(paidAmount), color: "#22c55e" },
|
||||
{ label: "Outstanding", value: fmt(outstandingAmount), color: "#f59e0b" },
|
||||
{ label: "Overdue", value: fmt(overdueAmount), color: "#f87171" },
|
||||
].map(c => `
|
||||
<div style="background:#f9f9f9;border-radius:12px;padding:16px;">
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:6px;">${c.label}</div>
|
||||
<div style="font-size:18px;font-weight:800;color:${c.color};">${c.value}</div>
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
|
||||
<!-- Milestones table -->
|
||||
<div style="margin-bottom:32px;">
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:12px;">Payment Milestones</div>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:2px solid #f0f0f0;">
|
||||
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;width:32px;">#</th>
|
||||
<th style="text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Milestone</th>
|
||||
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Due Date</th>
|
||||
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">%</th>
|
||||
<th style="text-align:right;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Amount</th>
|
||||
<th style="text-align:center;font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#aaa;font-weight:700;padding:8px 12px;">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${milestoneRows || `<tr><td colspan="6" style="padding:24px;text-align:center;color:#aaa;font-size:13px;">No milestones defined</td></tr>`}
|
||||
${milestones.length > 0 ? `
|
||||
<tr style="background:#fffbeb;">
|
||||
<td colspan="3" style="padding:12px;font-weight:700;font-size:13px;color:#111;">Total</td>
|
||||
<td style="padding:12px;text-align:right;font-weight:700;font-size:13px;">${totalPercent.toFixed(1)}%</td>
|
||||
<td style="padding:12px;text-align:right;font-weight:800;font-size:15px;color:#f59e0b;">${fmt(jobTotal)}</td>
|
||||
<td></td>
|
||||
</tr>` : ""}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Banking details -->
|
||||
${studio?.invoiceFooter ? `
|
||||
<div style="padding:20px;margin-bottom:32px;">
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:8px;">Notes</div>
|
||||
<div style="font-size:12px;color:#aaa;text-align:center;">${studio.invoiceFooter}</div>
|
||||
</div>` : ""}
|
||||
|
||||
<!-- Notes -->
|
||||
${schedule?.notes ? `
|
||||
<div style="margin-bottom:32px;">
|
||||
<div style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:#aaa;margin-bottom:8px;">Schedule Notes</div>
|
||||
<div style="font-size:13px;color:#555;white-space:pre-line;line-height:1.6;">${schedule.notes}</div>
|
||||
</div>` : ""}
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="border-top:1px solid #f0f0f0;padding-top:24px;text-align:center;font-size:11px;color:#bbb;">
|
||||
<p>Generated ${format(new Date(), "d MMM yyyy 'at' HH:mm")} · ${job.projectCode} Payment Schedule</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Disposition": `inline; filename="${job.projectCode}-payment-schedule.html"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getClient } from "@/actions/clients";
|
||||
import { ClientForm } from "@/components/clients/client-form";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { JOB_STATUSES } from "@/lib/constants";
|
||||
|
||||
export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const client = await getClient(id);
|
||||
if (!client) notFound();
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<Link href="/clients" className="flex items-center gap-2 text-zinc-400 hover:text-white mb-6 text-sm transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" /> Back to Clients
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Edit form */}
|
||||
<div className="lg:col-span-2">
|
||||
<h1 className="text-3xl font-bold text-white mb-8">{client.companyName}</h1>
|
||||
<ClientForm initialData={{
|
||||
companyName: client.companyName,
|
||||
contactPerson: client.contactPerson ?? undefined,
|
||||
email: client.email ?? undefined,
|
||||
phone: client.phone ?? undefined,
|
||||
vatNumber: client.vatNumber ?? undefined,
|
||||
companyRegNumber: client.companyRegNumber ?? undefined,
|
||||
billingAddress: client.billingAddress ?? undefined,
|
||||
notes: client.notes ?? undefined,
|
||||
}} clientId={id} />
|
||||
</div>
|
||||
|
||||
{/* Jobs sidebar */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Jobs ({client.jobs.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{client.jobs.map((job) => (
|
||||
<Link
|
||||
key={job.id}
|
||||
href={`/jobs/${job.id}`}
|
||||
className="block p-3 rounded-lg bg-zinc-900 border border-zinc-800 hover:border-amber-500/50 transition-colors"
|
||||
>
|
||||
<div className="font-medium text-white text-sm">{job.projectTitle}</div>
|
||||
<div className="text-xs text-zinc-500 mt-1 flex gap-2">
|
||||
<span>{job.projectCode}</span>
|
||||
<span>·</span>
|
||||
<span className={job.status === "APPROVED" ? "text-green-400" : "text-zinc-400"}>
|
||||
{JOB_STATUSES.find((s) => s.value === job.status)?.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-600 mt-1">
|
||||
{job._count.shots} shots · {job._count.assets} assets
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{client.jobs.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No jobs yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ClientForm } from "@/components/clients/client-form";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NewClientPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<Link href="/clients" className="flex items-center gap-2 text-zinc-400 hover:text-white mb-6 text-sm transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" /> Back to Clients
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-white mb-8">New Client</h1>
|
||||
<ClientForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getClients } from "@/actions/clients";
|
||||
import { ClientsTable } from "@/components/clients/clients-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
const clients = JSON.parse(JSON.stringify(await getClients()));
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Clients</h1>
|
||||
<p className="text-zinc-400 mt-1">{clients.length} client{clients.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/clients/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 h-8 bg-amber-500 hover:bg-amber-400 text-black text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Client
|
||||
</Link>
|
||||
</div>
|
||||
<ClientsTable clients={clients} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+120
-16
@@ -1,26 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getInvoice } from "@/actions/invoices";
|
||||
import { InvoiceForm } from "@/components/invoices/invoice-form";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const metadata = { title: "Edit Invoice | xBid" };
|
||||
|
||||
export default async function EditInvoicePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const [invoice, clients, jobs] = await Promise.all([
|
||||
getInvoice(id),
|
||||
prisma.client.findMany({ select: { id: true, companyName: true }, orderBy: { companyName: "asc" } }),
|
||||
prisma.job.findMany({ select: { id: true, projectTitle: true, projectCode: true, clientId: true }, orderBy: { createdAt: "desc" } }),
|
||||
]);
|
||||
if (!invoice) notFound();
|
||||
return <InvoiceForm invoice={invoice as any} clients={clients} jobs={jobs} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getInvoice } from "@/actions/invoices";
|
||||
import { InvoiceDetail } from "@/components/invoices/invoice-detail";
|
||||
|
||||
export const metadata = { title: "Invoice | xBid" };
|
||||
|
||||
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const invoice = await getInvoice(id);
|
||||
if (!invoice) notFound();
|
||||
return <InvoiceDetail invoice={JSON.parse(JSON.stringify(invoice))} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { InvoiceForm } from "@/components/invoices/invoice-form";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const metadata = { title: "New Invoice | xBid" };
|
||||
|
||||
export default async function NewInvoicePage() {
|
||||
const [clients, jobs] = await Promise.all([
|
||||
prisma.client.findMany({ select: { id: true, companyName: true }, orderBy: { companyName: "asc" } }),
|
||||
prisma.job.findMany({ select: { id: true, projectTitle: true, projectCode: true, clientId: true }, orderBy: { createdAt: "desc" } }),
|
||||
]);
|
||||
return <InvoiceForm clients={clients} jobs={jobs} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getInvoices } from "@/actions/invoices";
|
||||
import { InvoicesClient } from "@/components/invoices/invoices-client";
|
||||
|
||||
export const metadata = { title: "Invoices | xBid" };
|
||||
|
||||
export default async function InvoicesPage() {
|
||||
const invoices = JSON.parse(JSON.stringify(await getInvoices()));
|
||||
return <InvoicesClient initialInvoices={invoices} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getJob } from "@/actions/jobs";
|
||||
import { getClients } from "@/actions/clients";
|
||||
import { getRateCards } from "@/actions/rate-cards";
|
||||
import { getTaskTemplates } from "@/actions/shots-assets";
|
||||
import { notFound } from "next/navigation";
|
||||
import { JobDetailView } from "@/components/jobs/job-detail-view";
|
||||
import { calcLineItem, sumLineItems, applyJobAdjustments } from "@/lib/calculations";
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
export default async function JobDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const [job, clients, rateCards, taskTemplates] = await Promise.all([
|
||||
getJob(id),
|
||||
getClients(),
|
||||
getRateCards(),
|
||||
getTaskTemplates(),
|
||||
]);
|
||||
|
||||
if (!job) notFound();
|
||||
|
||||
// Compute grand total from all line items
|
||||
const allResults = [
|
||||
...job.shots.flatMap(s => s.lineItems.map(li => calcLineItem(li as any, s.complexityMultiplier.toString()))),
|
||||
...job.assets.flatMap(a => a.lineItems.map(li => calcLineItem(li as any, a.complexityMultiplier.toString()))),
|
||||
...(job.supervisions ?? []).flatMap((sv: any) => sv.lineItems.map((li: any) => calcLineItem(li as any, sv.complexityMultiplier.toString()))),
|
||||
...(job.previses ?? []).flatMap((pv: any) => pv.lineItems.map((li: any) => calcLineItem(li as any, pv.complexityMultiplier.toString()))),
|
||||
];
|
||||
const sub = allResults.reduce((s, r) => s.add(r.subtotal), new Decimal(0));
|
||||
const { grandTotal } = applyJobAdjustments(sub, job.markupPercent.toString(), job.discountPercent.toString(), job.taxPercent.toString());
|
||||
const productionFeeAmt = grandTotal.mul(new Decimal((job as any).productionFeePercent ?? 0).div(100));
|
||||
const pullManagementFeeAmt = grandTotal.mul(new Decimal((job as any).pullManagementFeePercent ?? 0).div(100));
|
||||
const grandTotalWithFees = grandTotal.add(productionFeeAmt).add(pullManagementFeeAmt);
|
||||
|
||||
const serialized = JSON.parse(JSON.stringify(job));
|
||||
const serializedClients = JSON.parse(JSON.stringify(clients));
|
||||
const serializedRateCards = JSON.parse(JSON.stringify(rateCards));
|
||||
return <JobDetailView job={serialized} clients={serializedClients} rateCards={serializedRateCards} taskTemplates={taskTemplates} jobTotal={grandTotalWithFees.toNumber()} />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getClients } from "@/actions/clients";
|
||||
import { JobForm } from "@/components/jobs/job-form";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export default async function NewJobPage() {
|
||||
const clients = await getClients();
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<Link href="/jobs" className="flex items-center gap-2 text-zinc-400 hover:text-white mb-6 text-sm transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" /> Back to Jobs
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-white mb-8">New Quote</h1>
|
||||
<JobForm clients={clients} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getJobs } from "@/actions/jobs";
|
||||
import { JobsTable } from "@/components/jobs/jobs-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
export default async function JobsPage() {
|
||||
const rawJobs = await getJobs();
|
||||
const jobs = JSON.parse(JSON.stringify(rawJobs));
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Jobs & Quotes</h1>
|
||||
<p className="text-zinc-400 mt-1">{jobs.length} job{jobs.length !== 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/jobs/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 h-8 bg-amber-500 hover:bg-amber-400 text-black text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Quote
|
||||
</Link>
|
||||
</div>
|
||||
<JobsTable jobs={jobs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+17
-22
@@ -1,33 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "xBid — VFX Quoting & Bidding",
|
||||
description: "Professional VFX quotes, bids, and invoices for VFX studios",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="en" className="dark h-full">
|
||||
<body className={`${inter.className} bg-background text-foreground antialiased h-full`}>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+36
-60
@@ -1,65 +1,41 @@
|
||||
import Image from "next/image";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { DashboardStats } from "@/components/dashboard/stats";
|
||||
import { RecentJobs } from "@/components/dashboard/recent-jobs";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const [jobCount, clientCount, draftCount, sentCount, approvedCount, recentJobs] =
|
||||
await Promise.all([
|
||||
prisma.job.count(),
|
||||
prisma.client.count(),
|
||||
prisma.job.count({ where: { status: "DRAFT" } }),
|
||||
prisma.job.count({ where: { status: "SENT" } }),
|
||||
prisma.job.count({ where: { status: "APPROVED" } }),
|
||||
prisma.job.findMany({
|
||||
take: 8,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { client: { select: { companyName: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
|
||||
<p className="text-zinc-400 mt-1">Welcome back to xBid VFX quoting.</p>
|
||||
</div>
|
||||
|
||||
<DashboardStats
|
||||
jobCount={jobCount}
|
||||
clientCount={clientCount}
|
||||
draftCount={draftCount}
|
||||
sentCount={sentCount}
|
||||
approvedCount={approvedCount}
|
||||
/>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Recent Jobs</h2>
|
||||
<RecentJobs jobs={recentJobs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getAllRateCards } from "@/actions/rate-cards";
|
||||
import { RateCardsClient } from "@/components/rate-cards/rate-cards-client";
|
||||
|
||||
export default async function RateCardsPage() {
|
||||
const rateCards = JSON.parse(JSON.stringify(await getAllRateCards()));
|
||||
return (
|
||||
<div className="p-8">
|
||||
<RateCardsClient initialRateCards={rateCards} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getStudioSettings } from "@/actions/settings";
|
||||
import { SettingsForm } from "@/components/settings/settings-form";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const rawSettings = await getStudioSettings();
|
||||
const settings = JSON.parse(JSON.stringify(rawSettings));
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-zinc-800 rounded-lg">
|
||||
<Settings className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Studio Settings</h1>
|
||||
<p className="text-sm text-zinc-400">Configure your studio details, branding, and quote defaults.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6">
|
||||
<SettingsForm initialData={settings} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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. 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,160 @@
|
||||
import Decimal from "decimal.js";
|
||||
|
||||
// Configure Decimal.js for financial precision
|
||||
Decimal.set({ precision: 20, rounding: Decimal.ROUND_HALF_UP });
|
||||
|
||||
export type LineItemInput = {
|
||||
quantityHours: number | string;
|
||||
dailyRate: number | string;
|
||||
hoursPerDay: number | string;
|
||||
numArtists: number | string;
|
||||
numDays: number | string;
|
||||
manualHourlyRate?: number | string | null;
|
||||
manualSubtotal?: number | string | null;
|
||||
markupPercent: number | string;
|
||||
discountPercent: number | string;
|
||||
taxPercent: number | string;
|
||||
complexityMultiplier?: number | string;
|
||||
};
|
||||
|
||||
export type LineItemCalcResult = {
|
||||
hourlyRate: Decimal;
|
||||
subtotal: Decimal;
|
||||
markupAmount: Decimal;
|
||||
discountAmount: Decimal;
|
||||
taxAmount: Decimal;
|
||||
total: Decimal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate all derived values for a line item.
|
||||
* All arithmetic uses Decimal.js to avoid floating-point drift.
|
||||
*/
|
||||
export function calcLineItem(
|
||||
input: LineItemInput,
|
||||
complexityMultiplier?: number | string
|
||||
): LineItemCalcResult {
|
||||
const hoursPerDay = new Decimal(input.hoursPerDay || 8);
|
||||
const dailyRate = new Decimal(input.dailyRate || 0);
|
||||
const numArtists = new Decimal(input.numArtists || 1);
|
||||
const numDays = new Decimal(input.numDays || 0);
|
||||
const quantityHours = new Decimal(input.quantityHours || 0);
|
||||
const markupPct = new Decimal(input.markupPercent || 0).div(100);
|
||||
const discountPct = new Decimal(input.discountPercent || 0).div(100);
|
||||
const taxPct = new Decimal(input.taxPercent || 0).div(100);
|
||||
const complexity = new Decimal(complexityMultiplier ?? input.complexityMultiplier ?? 1);
|
||||
|
||||
// Hourly rate: manual override or derived from daily rate
|
||||
const hourlyRate = input.manualHourlyRate != null
|
||||
? new Decimal(input.manualHourlyRate)
|
||||
: hoursPerDay.gt(0)
|
||||
? dailyRate.div(hoursPerDay)
|
||||
: new Decimal(0);
|
||||
|
||||
// Subtotal: manual override or hours × hourly × artists × complexity
|
||||
let subtotal: Decimal;
|
||||
if (input.manualSubtotal != null) {
|
||||
subtotal = new Decimal(input.manualSubtotal);
|
||||
} else if (numDays.gt(0)) {
|
||||
// numDays-based: days × artists × daily rate × complexity
|
||||
subtotal = numDays.mul(numArtists).mul(dailyRate).mul(complexity);
|
||||
} else {
|
||||
// hours-based
|
||||
subtotal = quantityHours.mul(hourlyRate).mul(numArtists).mul(complexity);
|
||||
}
|
||||
|
||||
const markupAmount = subtotal.mul(markupPct);
|
||||
const afterMarkup = subtotal.add(markupAmount);
|
||||
const discountAmount = afterMarkup.mul(discountPct);
|
||||
const afterDiscount = afterMarkup.sub(discountAmount);
|
||||
const taxAmount = afterDiscount.mul(taxPct);
|
||||
const total = afterDiscount.add(taxAmount);
|
||||
|
||||
return {
|
||||
hourlyRate,
|
||||
subtotal,
|
||||
markupAmount,
|
||||
discountAmount,
|
||||
taxAmount,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
type SummableItem = {
|
||||
subtotal: Decimal;
|
||||
markupAmount: Decimal;
|
||||
discountAmount: Decimal;
|
||||
taxAmount: Decimal;
|
||||
total: Decimal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aggregate line item totals for a shot or asset.
|
||||
*/
|
||||
export function sumLineItems(
|
||||
items: SummableItem[]
|
||||
): { subtotal: Decimal; markupAmount: Decimal; discountAmount: Decimal; taxAmount: Decimal; total: Decimal } {
|
||||
return items.reduce(
|
||||
(acc, item) => ({
|
||||
subtotal: acc.subtotal.add(item.subtotal),
|
||||
markupAmount: acc.markupAmount.add(item.markupAmount),
|
||||
discountAmount: acc.discountAmount.add(item.discountAmount),
|
||||
taxAmount: acc.taxAmount.add(item.taxAmount),
|
||||
total: acc.total.add(item.total),
|
||||
}),
|
||||
{
|
||||
subtotal: new Decimal(0),
|
||||
markupAmount: new Decimal(0),
|
||||
discountAmount: new Decimal(0),
|
||||
taxAmount: new Decimal(0),
|
||||
total: new Decimal(0),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply job-level markup/discount/tax to a pre-calculated subtotal.
|
||||
*/
|
||||
export function applyJobAdjustments(
|
||||
subtotal: Decimal,
|
||||
markupPct: number | string,
|
||||
discountPct: number | string,
|
||||
taxPct: number | string
|
||||
): { markupAmount: Decimal; discountAmount: Decimal; taxAmount: Decimal; grandTotal: Decimal } {
|
||||
const markup = new Decimal(markupPct || 0).div(100);
|
||||
const discount = new Decimal(discountPct || 0).div(100);
|
||||
const tax = new Decimal(taxPct || 0).div(100);
|
||||
|
||||
const markupAmount = subtotal.mul(markup);
|
||||
const afterMarkup = subtotal.add(markupAmount);
|
||||
const discountAmount = afterMarkup.mul(discount);
|
||||
const afterDiscount = afterMarkup.sub(discountAmount);
|
||||
const taxAmount = afterDiscount.mul(tax);
|
||||
const grandTotal = afterDiscount.add(taxAmount);
|
||||
|
||||
return { markupAmount, discountAmount, taxAmount, grandTotal };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Decimal or number as currency string.
|
||||
*/
|
||||
export function formatCurrency(
|
||||
value: Decimal | number | string,
|
||||
currency = "ZAR",
|
||||
locale = "en-ZA"
|
||||
): string {
|
||||
const num = value instanceof Decimal ? value.toNumber() : Number(value);
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a Decimal to 2dp for display/storage.
|
||||
*/
|
||||
export function round2(value: Decimal): Decimal {
|
||||
return value.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
export const SHOT_LINE_ITEM_DEFAULTS = [
|
||||
"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",
|
||||
] as const;
|
||||
|
||||
export const ASSET_LINE_ITEM_DEFAULTS = [
|
||||
"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",
|
||||
] as const;
|
||||
|
||||
export const RATE_CARD_ROLES = [
|
||||
{ name: "Junior Compositor", department: "Compositing" },
|
||||
{ name: "Senior Compositor", department: "Compositing" },
|
||||
{ name: "Lead Compositor", department: "Compositing" },
|
||||
{ name: "FX TD", department: "FX" },
|
||||
{ name: "CG Generalist", department: "CG" },
|
||||
{ name: "Tracking Artist", department: "Tracking" },
|
||||
{ name: "Roto Artist", department: "Roto / Paint" },
|
||||
{ name: "Lighting Artist", department: "Lighting" },
|
||||
{ name: "Supervisor", department: "Production" },
|
||||
{ name: "Producer", department: "Production" },
|
||||
{ name: "Modeler", department: "CG" },
|
||||
{ name: "Rigger", department: "CG" },
|
||||
{ name: "Texture Artist", department: "CG" },
|
||||
{ name: "Matte Painter", department: "Compositing" },
|
||||
{ name: "Animation TD", department: "Animation" },
|
||||
] as const;
|
||||
|
||||
export const ASSET_TYPES = [
|
||||
"Character",
|
||||
"Prop",
|
||||
"Vehicle",
|
||||
"Environment",
|
||||
"Creature",
|
||||
"FX Element",
|
||||
"Digital Double",
|
||||
"Set Piece",
|
||||
] as const;
|
||||
|
||||
export const SUPERVISION_LINE_ITEM_DEFAULTS = {
|
||||
"Pre-Production": [
|
||||
"Script Breakdown",
|
||||
"VFX Planning",
|
||||
"Technical Consultation",
|
||||
"Tech Scout",
|
||||
"Previs Review",
|
||||
"Virtual Production Planning",
|
||||
"Equipment Planning",
|
||||
"Vendor Coordination",
|
||||
],
|
||||
"On-Set": [
|
||||
"On-Set VFX Supervision",
|
||||
"Data Wrangling",
|
||||
"Witness Camera Capture",
|
||||
"HDRI Capture",
|
||||
"Lidar Scanning",
|
||||
"Photogrammetry Capture",
|
||||
"Camera Reports",
|
||||
"Plate Verification",
|
||||
"Tracking Marker Supervision",
|
||||
"Green Screen Supervision",
|
||||
"Continuity Checks",
|
||||
"Playback Support",
|
||||
],
|
||||
"Post-Shoot": [
|
||||
"Data Processing",
|
||||
"Scan Cleanup",
|
||||
"Shoot Reporting",
|
||||
"Editorial Turnover",
|
||||
"Client Review Meetings",
|
||||
],
|
||||
"Travel & Expenses": [
|
||||
"Travel Days",
|
||||
"Accommodation",
|
||||
"Per Diem",
|
||||
"Vehicle Hire",
|
||||
"Flights",
|
||||
"Equipment Rental",
|
||||
"Insurance",
|
||||
"Overtime",
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const SUPERVISION_LINE_ITEM_FLAT = Object.values(SUPERVISION_LINE_ITEM_DEFAULTS).flat();
|
||||
|
||||
export const SUPERVISION_CREW_ROLES = [
|
||||
"VFX Supervisor",
|
||||
"On-Set Supervisor",
|
||||
"Data Wrangler",
|
||||
"Capture Technician",
|
||||
"VP Operator",
|
||||
"Tracking Supervisor",
|
||||
"Production Coordinator",
|
||||
] as const;
|
||||
|
||||
export const PREVIS_SEQUENCE_TYPES = [
|
||||
"Previs",
|
||||
"Techvis",
|
||||
"Stuntvis",
|
||||
"Pitchvis",
|
||||
"Postvis",
|
||||
"Virtual Production Blocking",
|
||||
] as const;
|
||||
|
||||
export const PREVIS_DELIVERY_STAGES = [
|
||||
"Blocking",
|
||||
"First Pass",
|
||||
"Client Review",
|
||||
"Final Previs",
|
||||
"Approved",
|
||||
] as const;
|
||||
|
||||
export const PREVIS_LINE_ITEM_DEFAULTS = {
|
||||
"Editorial": [
|
||||
"Storyboard Edit",
|
||||
"Animatic Assembly",
|
||||
"Sequence Timing",
|
||||
"Temp Audio Sync",
|
||||
"Camera Edit",
|
||||
],
|
||||
"Animation": [
|
||||
"Layout",
|
||||
"Blocking",
|
||||
"Character Animation",
|
||||
"Vehicle Animation",
|
||||
"Crowd Animation",
|
||||
"Motion Polish",
|
||||
],
|
||||
"Techvis": [
|
||||
"Camera Planning",
|
||||
"Lens Planning",
|
||||
"Crane Planning",
|
||||
"Stunt Planning",
|
||||
"Scale Validation",
|
||||
"Environment Layout",
|
||||
],
|
||||
"Environment / CG": [
|
||||
"Temp Modeling",
|
||||
"Temp Texturing",
|
||||
"Temp Lighting",
|
||||
"Proxy Asset Setup",
|
||||
"Scene Assembly",
|
||||
],
|
||||
"Virtual Production": [
|
||||
"VP Scene Prep",
|
||||
"LED Volume Layout",
|
||||
"Camera Calibration",
|
||||
"Unreal Integration",
|
||||
],
|
||||
"Review": [
|
||||
"Director Review Sessions",
|
||||
"Client Notes",
|
||||
"Revision Passes",
|
||||
"Delivery Packaging",
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const PREVIS_LINE_ITEM_FLAT = Object.values(PREVIS_LINE_ITEM_DEFAULTS).flat();
|
||||
|
||||
export const PREVIS_CREW_ROLES = [
|
||||
"Previs Artist",
|
||||
"Layout Artist",
|
||||
"Techvis Artist",
|
||||
"Animation Lead",
|
||||
"VP Technical Artist",
|
||||
"Unreal Artist",
|
||||
"Editorial Artist",
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_CURRENCY = "ZAR";
|
||||
|
||||
export const CURRENCIES = [
|
||||
{ code: "ZAR", symbol: "R", name: "South African Rand" },
|
||||
{ code: "USD", symbol: "$", name: "US Dollar" },
|
||||
{ code: "GBP", symbol: "£", name: "British Pound" },
|
||||
{ code: "EUR", symbol: "€", name: "Euro" },
|
||||
{ code: "AUD", symbol: "A$", name: "Australian Dollar" },
|
||||
{ code: "CAD", symbol: "C$", name: "Canadian Dollar" },
|
||||
{ code: "NZD", symbol: "NZ$", name: "New Zealand Dollar" },
|
||||
] as const;
|
||||
|
||||
export const JOB_STATUSES = [
|
||||
{ value: "DRAFT", label: "Draft", color: "gray" },
|
||||
{ value: "SENT", label: "Sent", color: "blue" },
|
||||
{ value: "APPROVED", label: "Approved", color: "green" },
|
||||
{ value: "REJECTED", label: "Rejected", color: "red" },
|
||||
{ value: "ARCHIVED", label: "Archived", color: "yellow" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_HOURS_PER_DAY = 8;
|
||||
export const DEFAULT_MARKUP_PERCENT = 0;
|
||||
export const DEFAULT_TAX_PERCENT = 0;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
function createPrismaClient() {
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
}
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: ReturnType<typeof createPrismaClient> | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── Client ───────────────────────────────────────────────────────────────────
|
||||
export const clientSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
contactPerson: z.string().optional(),
|
||||
email: z.string().email("Invalid email").optional().or(z.literal("")),
|
||||
phone: z.string().optional(),
|
||||
vatNumber: z.string().optional(),
|
||||
companyRegNumber: z.string().optional(),
|
||||
billingAddress: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type ClientFormValues = z.infer<typeof clientSchema>;
|
||||
|
||||
// ─── Job ──────────────────────────────────────────────────────────────────────
|
||||
export const jobSchema = z.object({
|
||||
projectTitle: z.string().min(1, "Project title is required"),
|
||||
projectCode: z.string().min(1, "Project code is required"),
|
||||
showId: z.string().min(1, "Show ID is required").max(10, "Max 10 characters").regex(/^[A-Z0-9_]+$/i, "Letters, numbers and underscores only"),
|
||||
jobType: z.enum(["STANDARD", "EPISODIC"]).default("STANDARD"),
|
||||
clientId: z.string().min(1, "Client is required"),
|
||||
issueDate: z.string().min(1, "Issue date is required"),
|
||||
expiryDate: z.string().optional(),
|
||||
startDate: z.string().optional(),
|
||||
estimatedCompletionDate: z.string().optional(),
|
||||
currency: z.string().min(1),
|
||||
status: z.enum(["DRAFT", "SENT", "APPROVED", "REJECTED", "ARCHIVED"]),
|
||||
description: z.string().optional(),
|
||||
internalNotes: z.string().optional(),
|
||||
markupPercent: z.coerce.number().min(0).max(100),
|
||||
taxPercent: z.coerce.number().min(0).max(100),
|
||||
discountPercent: z.coerce.number().min(0).max(100),
|
||||
productionFeePercent: z.coerce.number().min(0).max(100).default(0),
|
||||
pullManagementFeePercent: z.coerce.number().min(0).max(100).default(0),
|
||||
customHeaderHtml: z.string().optional(),
|
||||
customFooterHtml: z.string().optional(),
|
||||
termsAndConditions: z.string().optional(),
|
||||
});
|
||||
export type JobFormValues = z.infer<typeof jobSchema>;
|
||||
|
||||
// ─── Shot ──────────────────────────────────────────────────────────────────────
|
||||
export const shotSchema = z.object({
|
||||
shotCode: z.string().optional(),
|
||||
scene: z.string().min(1, "Scene is required"),
|
||||
episode: z.string().optional(),
|
||||
shotGroup: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
durationSeconds: z.coerce.number().min(0).optional(),
|
||||
complexityMultiplier: z.coerce.number().min(0.1).max(10).default(1),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type ShotFormValues = z.infer<typeof shotSchema>;
|
||||
|
||||
// ─── Asset ──────────────────────────────────────────────────────────────────────
|
||||
export const assetSchema = z.object({
|
||||
assetName: z.string().min(1, "Asset name is required"),
|
||||
assetType: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
complexityMultiplier: z.coerce.number().min(0.1).max(10).default(1),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type AssetFormValues = z.infer<typeof assetSchema>;
|
||||
|
||||
// ─── Supervision ───────────────────────────────────────────────────────────────
|
||||
export const supervisionSchema = z.object({
|
||||
supervisionTitle: z.string().min(1, "Title is required"),
|
||||
shootName: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
shootDates: z.string().optional(),
|
||||
numShootDays: z.coerce.number().min(0).default(0),
|
||||
travelDays: z.coerce.number().min(0).default(0),
|
||||
prepDays: z.coerce.number().min(0).default(0),
|
||||
wrapDays: z.coerce.number().min(0).default(0),
|
||||
description: z.string().optional(),
|
||||
complexityMultiplier: z.coerce.number().min(0.1).max(10).default(1),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type SupervisionFormValues = z.infer<typeof supervisionSchema>;
|
||||
|
||||
// ─── Previs ────────────────────────────────────────────────────────────────────
|
||||
export const previsSchema = z.object({
|
||||
sequenceName: z.string().min(1, "Sequence name is required"),
|
||||
previsCode: z.string().optional(),
|
||||
sequenceType: z.string().default("Previs"),
|
||||
description: z.string().optional(),
|
||||
duration: z.string().optional(),
|
||||
frameRange: z.string().optional(),
|
||||
revisionCount: z.coerce.number().min(0).default(0),
|
||||
deliveryStage: z.string().default("Blocking"),
|
||||
complexityMultiplier: z.coerce.number().min(0.1).max(10).default(1),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type PrevisFormValues = z.infer<typeof previsSchema>;
|
||||
|
||||
// ─── Line Item ─────────────────────────────────────────────────────────────────
|
||||
export const lineItemSchema = z.object({
|
||||
taskName: z.string().min(1, "Task name is required"),
|
||||
artistRole: z.string().optional(),
|
||||
rateCardId: z.string().optional(),
|
||||
quantityHours: z.coerce.number().min(0).default(0),
|
||||
dailyRate: z.coerce.number().min(0).default(0),
|
||||
hoursPerDay: z.coerce.number().min(1).max(24).default(8),
|
||||
numArtists: z.coerce.number().min(0).default(1),
|
||||
numDays: z.coerce.number().min(0).default(0),
|
||||
manualHourlyRate: z.coerce.number().min(0).optional().nullable(),
|
||||
manualSubtotal: z.coerce.number().min(0).optional().nullable(),
|
||||
markupPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
discountPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
taxPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type LineItemFormValues = z.infer<typeof lineItemSchema>;
|
||||
|
||||
// ─── Task Template ────────────────────────────────────────────────────────────
|
||||
export const taskTemplateSchema = z.object({
|
||||
name: z.string().min(1, "Template name is required"),
|
||||
taskName: z.string().min(1, "Task name is required"),
|
||||
artistRole: z.string().optional(),
|
||||
rateCardId: z.string().optional(),
|
||||
quantityHours: z.coerce.number().min(0).default(0),
|
||||
dailyRate: z.coerce.number().min(0).default(0),
|
||||
hoursPerDay: z.coerce.number().min(1).max(24).default(8),
|
||||
numArtists: z.coerce.number().min(0).default(1),
|
||||
numDays: z.coerce.number().min(0).default(0),
|
||||
manualHourlyRate: z.coerce.number().min(0).optional().nullable(),
|
||||
manualSubtotal: z.coerce.number().min(0).optional().nullable(),
|
||||
markupPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
discountPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
taxPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type TaskTemplateFormValues = z.infer<typeof taskTemplateSchema>;
|
||||
|
||||
// ─── Rate Card ─────────────────────────────────────────────────────────────────
|
||||
export const rateCardSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
department: z.string().optional(),
|
||||
defaultDailyRate: z.coerce.number().min(0),
|
||||
hoursPerDay: z.coerce.number().min(1).max(24).default(8),
|
||||
currency: z.string().min(1).default("ZAR"),
|
||||
notes: z.string().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
export type RateCardFormValues = z.infer<typeof rateCardSchema>;
|
||||
|
||||
// ─── Studio Settings ──────────────────────────────────────────────────────────
|
||||
export const studioSettingsSchema = z.object({
|
||||
studioName: z.string().min(1, "Studio name is required"),
|
||||
studioEmail: z.string().email("Invalid email").optional().or(z.literal("")),
|
||||
studioPhone: z.string().optional(),
|
||||
studioAddress: z.string().optional(),
|
||||
logoUrl: z.string().optional(),
|
||||
vatNumber: z.string().optional(),
|
||||
companyRegNumber: z.string().optional(),
|
||||
invoiceHeader: z.string().optional(),
|
||||
invoiceFooter: z.string().optional(),
|
||||
defaultTermsAndConditions: z.string().optional(),
|
||||
defaultCurrency: z.string().min(1).default("ZAR"),
|
||||
defaultMarkupPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
defaultDiscountPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
defaultTaxPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
});
|
||||
export type StudioSettingsFormValues = z.infer<typeof studioSettingsSchema>;
|
||||
|
||||
// ─── Payment Schedule ──────────────────────────────────────────────────────────
|
||||
export const milestoneSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
description: z.string().optional(),
|
||||
dueDate: z.string().optional(),
|
||||
percentOfTotal: z.coerce.number().min(0).max(100).default(0),
|
||||
fixedAmount: z.coerce.number().min(0).optional().nullable(),
|
||||
status: z.enum(["DRAFT", "PENDING", "INVOICED", "PARTIALLY_PAID", "PAID", "OVERDUE", "CANCELLED"]).default("PENDING"),
|
||||
paymentReceivedDate: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
});
|
||||
export type MilestoneFormValues = z.infer<typeof milestoneSchema>;
|
||||
|
||||
export const paymentScheduleSchema = z.object({
|
||||
template: z.enum(["STANDARD_FILM", "SMALL_COMMERCIAL", "EPISODIC", "CUSTOM"]).default("CUSTOM"),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type PaymentScheduleFormValues = z.infer<typeof paymentScheduleSchema>;
|
||||
|
||||
// ─── Invoice ──────────────────────────────────────────────────────────────────
|
||||
export const invoiceSchema = z.object({
|
||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||
clientId: z.string().min(1, "Client is required"),
|
||||
jobId: z.string().min(1, "Job is required"),
|
||||
milestoneId: z.string().optional().nullable(),
|
||||
status: z.enum(["DRAFT", "SENT", "VIEWED", "PARTIALLY_PAID", "PAID", "OVERDUE", "CANCELLED"]).default("DRAFT"),
|
||||
issueDate: z.string().min(1),
|
||||
dueDate: z.string().optional(),
|
||||
paymentTerms: z.string().optional(),
|
||||
currency: z.string().min(1).default("ZAR"),
|
||||
subtotal: z.coerce.number().min(0),
|
||||
taxPercent: z.coerce.number().min(0).max(100).default(0),
|
||||
taxAmount: z.coerce.number().min(0).default(0),
|
||||
totalAmount: z.coerce.number().min(0),
|
||||
notes: z.string().optional(),
|
||||
internalNotes: z.string().optional(),
|
||||
bankingDetails: z.string().optional(),
|
||||
});
|
||||
export type InvoiceFormValues = z.infer<typeof invoiceSchema>;
|
||||
|
||||
export const invoicePaymentSchema = z.object({
|
||||
amount: z.coerce.number().min(0.01, "Amount must be positive"),
|
||||
paymentDate: z.string().min(1, "Payment date is required"),
|
||||
paymentMethod: z.enum(["EFT", "CASH", "CREDIT_CARD", "BANK_TRANSFER", "CRYPTO", "OTHER"]).default("EFT"),
|
||||
reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type InvoicePaymentFormValues = z.infer<typeof invoicePaymentSchema>;
|
||||
Reference in New Issue
Block a user