Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,57 @@ export default function RootLayout({
<html
lang="en"
className={`${GeistSans.variable} ${GeistMono.variable} ${fraunces.variable} h-full antialiased`}
suppressHydrationWarning
>
<body className="min-h-full flex font-sans">
<body
className="min-h-full flex font-sans"
suppressHydrationWarning
>
{/* Strip Grammarly (and similar) body attrs before hydration — React 19 dev overlay can still warn even with suppressHydrationWarning. */}
<script
dangerouslySetInnerHTML={{
__html: `
(function () {
if (typeof document === "undefined") return;
function isExtBodyAttr(name) {
return (
name === "data-gr-ext-installed" ||
name === "data-new-gr-c-s-check-loaded" ||
name.startsWith("data-gr-") ||
name.startsWith("data-grammarly")
);
}
function stripBody() {
var b = document.body;
if (!b || !b.getAttributeNames) return;
var names = b.getAttributeNames();
for (var i = 0; i < names.length; i++) {
var n = names[i];
if (isExtBodyAttr(n)) {
try {
b.removeAttribute(n);
} catch (_) {}
}
}
}
function arm() {
var b = document.body;
if (!b) return;
stripBody();
try {
new MutationObserver(function (muts) {
for (var j = 0; j < muts.length; j++) {
if (muts[j].type === "attributes" && muts[j].target === b) stripBody();
}
}).observe(b, { attributes: true });
} catch (_) {}
}
if (document.body) arm();
else document.addEventListener("DOMContentLoaded", arm, { once: true });
})();
`.trim(),
}}
/>
<Sidebar />
<main className="flex-1 overflow-auto relative">{children}</main>
<CommandPaletteData />
Expand Down
133 changes: 133 additions & 0 deletions app/shows/[id]/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"use server";

import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { settlements } from "@/db/schema";
import type { DealAmbiguityClarificationQuestion } from "@/lib/dealAmbiguity";
import { generateClarificationEmailBody } from "@/lib/clarificationEmailDraft";
import { mergeShowReadinessAnswer } from "@/lib/queries";

const BLOCKED_WORKSPACE_SAVE_STATUSES = new Set([
"paid",
"voided",
]);

export type WorkspaceSettlementSavePayload = {
totalToArtist: number;
grossBoxOffice: number;
netBoxOffice: number;
totalExpenses: number;
snapshot: Record<string, unknown>;
};

export async function saveWorkspaceSettlement(
showId: string,
payload: WorkspaceSettlementSavePayload
): Promise<{ ok: true } | { ok: false; error: string }> {
const {
totalToArtist,
grossBoxOffice,
netBoxOffice,
totalExpenses,
snapshot,
} = payload;
for (const [key, n] of [
["totalToArtist", totalToArtist],
["grossBoxOffice", grossBoxOffice],
["netBoxOffice", netBoxOffice],
["totalExpenses", totalExpenses],
] as const) {
if (typeof n !== "number" || !Number.isFinite(n)) {
return { ok: false, error: `Invalid number for ${key}` };
}
}

const now = new Date();
const calculationJson = JSON.stringify({
...snapshot,
source: "settlement_workspace",
savedAt: now.toISOString(),
});

try {
const existing = await db
.select()
.from(settlements)
.where(eq(settlements.showId, showId))
.limit(1);

const row = existing[0];
if (row && BLOCKED_WORKSPACE_SAVE_STATUSES.has(row.status)) {
return {
ok: false,
error: `Cannot save from workspace while settlement is ${row.status}.`,
};
}

if (row) {
await db
.update(settlements)
.set({
grossBoxOffice,
netBoxOffice,
totalExpenses,
totalToArtist,
calculationJson,
})
.where(eq(settlements.id, row.id));
} else {
await db.insert(settlements).values({
id: `stl_${showId}`,
showId,
status: "draft",
draftedAt: now,
grossBoxOffice,
netBoxOffice,
totalExpenses,
totalToArtist,
calculationJson,
});
}

revalidatePath(`/shows/${showId}`);
revalidatePath(`/shows/${showId}/workspace`);
revalidatePath(`/shows/${showId}/settle`);
return { ok: true };
} catch (e) {
const message = e instanceof Error ? e.message : "Save failed";
return { ok: false, error: message };
}
}

export async function requestClarificationEmailDraft(
questions: DealAmbiguityClarificationQuestion[]
): Promise<{ ok: true; body: string } | { ok: false; error: string }> {
try {
if (!Array.isArray(questions) || questions.length === 0) {
return { ok: false, error: "No clarification questions to draft from." };
}
const body = await generateClarificationEmailBody(questions);
return { ok: true, body };
} catch (e) {
const message = e instanceof Error ? e.message : "Draft failed";
return { ok: false, error: message };
}
}

export async function saveReadinessClarificationAnswer(
showId: string,
questionId: string,
option: string,
questionType: "single_select" | "multi_select"
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
await mergeShowReadinessAnswer(showId, questionId, option, questionType);
revalidatePath(`/shows/${showId}`);
revalidatePath(`/shows/${showId}/workspace`);
return { ok: true };
} catch (e) {
const message = e instanceof Error ? e.message : "Save failed";
return { ok: false, error: message };
}
}
47 changes: 41 additions & 6 deletions app/shows/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import {
ArrowLeft,
Calculator,
FileSpreadsheet,
AlertCircle,
Clock,
TrendingUp,
} from "lucide-react";
import { getShowById } from "@/lib/queries";
import { checkDealNotesAmbiguity, type DealAmbiguityReview } from "@/lib/dealAmbiguity";
import { SettlementReadinessLauncher } from "./settlement-readiness-modal";
import {
Card,
CardContent,
Expand Down Expand Up @@ -77,6 +80,18 @@ export default async function ShowDetailPage({

const isDisputed = settlement?.status === "disputed";

const dealNotesTrimmed = deal?.dealNotesFreetext?.trim() ?? "";
let readinessReview: DealAmbiguityReview | null = null;
let readinessError: string | null = null;
if (dealNotesTrimmed && deal?.dealType === "vs") {
try {
readinessReview = await checkDealNotesAmbiguity(dealNotesTrimmed);
} catch (e) {
readinessError =
e instanceof Error ? e.message : "Could not complete AI review.";
}
}

return (
<div className="max-w-7xl">
{/* Poster header */}
Expand Down Expand Up @@ -119,12 +134,22 @@ export default async function ShowDetailPage({
</span>
</div>
</div>
<Link href={`/shows/${show.id}/settle`} className="mt-6 shrink-0">
<Button variant="brand" size="lg">
<FileSpreadsheet className="h-4 w-4" />
{settlement ? "View settlement" : "Settle show"}
</Button>
</Link>
<div className="mt-6 shrink-0 flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:gap-3">
<Link href={`/shows/${show.id}/settle`}>
<Button variant="brand" size="lg" className="w-full sm:w-auto gap-2">
<FileSpreadsheet className="h-4 w-4" />
{settlement ? "View settlement" : "Settle show"}
</Button>
</Link>
{deal?.dealType === "vs" && (
<Link href={`/shows/${show.id}/workspace`}>
<Button variant="secondary" size="lg" className="w-full sm:w-auto gap-2">
<Calculator className="h-4 w-4" />
Calculate settlement workspace
</Button>
</Link>
)}
</div>
</div>

{/* Key numbers strip */}
Expand Down Expand Up @@ -169,6 +194,16 @@ export default async function ShowDetailPage({
<CardContent className="space-y-5">
{deal ? (
<>
{deal.dealType === "vs" && (
<SettlementReadinessLauncher
showId={show.id}
dealType={deal.dealType}
hasDealNotes={Boolean(dealNotesTrimmed)}
review={readinessReview}
error={readinessError}
readinessAnswersJson={show.readinessAnswersJson ?? null}
/>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Field
label="Guarantee"
Expand Down
Loading