diff --git a/.gitignore b/.gitignore index 5ef6a520..d32557ae 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.env.local diff --git a/README.md b/README.md index c48fd3d8..593556b2 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,19 @@ You'll see something like: ✓ Ready in 1.2s ``` +## AI Pre-Settlement Brief (API Key Required) + +The pre-settlement brief uses the Anthropic API. To enable it locally: + +1. Create a `.env.local` file in the project root +2. Add your Anthropic API key: +ANTHROPIC_API_KEY=your-key-here +3. Restart the dev server + +Get a free API key at https://console.anthropic.com + +The brief will show "Brief unavailable" without a key — all other features (including the Vs deal calculator) work normally without it. + ### 5. Open it in your browser diff --git a/app/api/settlement-brief/route.ts b/app/api/settlement-brief/route.ts new file mode 100644 index 00000000..62c9aeab --- /dev/null +++ b/app/api/settlement-brief/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { prompt } = body; + + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": process.env.ANTHROPIC_API_KEY ?? "", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-5", + max_tokens: 1000, + messages: [{ role: "user", content: prompt }], + }), + }); + + const data = await response.json(); + return NextResponse.json(data); +} \ No newline at end of file diff --git a/app/shows/[id]/PreSettlementBrief.tsx b/app/shows/[id]/PreSettlementBrief.tsx new file mode 100644 index 00000000..82310c89 --- /dev/null +++ b/app/shows/[id]/PreSettlementBrief.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AlertTriangle, CheckCircle, Lightbulb, Loader2, ChevronDown, ChevronUp } from "lucide-react"; + +interface BriefProps { + showId: string; + dealNotes: string; + dealType: string; + guaranteeAmount: number | null; + percentage: number | null; + expenseCap: number | null; + hospitalityCap: number | null; + totalExpenses: number; + showDate: string; + artistName: string; +} + +interface BriefResult { + flags: { severity: "high" | "medium" | "low"; text: string }[]; + expenseReadiness: { label: string; status: "ready" | "missing" | "warning"; note: string }[]; + suggestedAction: string; + summary: string; +} + +export function PreSettlementBrief(props: BriefProps) { + const [brief, setBrief] = useState(null); + const [loading, setLoading] = useState(false); + const [expanded, setExpanded] = useState(true); + const [error, setError] = useState(null); + + const showDate = new Date(props.showDate); + const now = new Date(); + const daysUntilShow = Math.ceil((showDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); + const isUpcoming = daysUntilShow > 0; + + useEffect(() => { + if (!isUpcoming) return; + generateBrief(); + }, []); + + async function generateBrief() { + setLoading(true); + setError(null); + try { + const prompt = `You are an experienced music venue settlement analyst. Analyze this show deal and generate a pre-settlement brief for the booker. + +SHOW: ${props.artistName} +DATE: ${props.showDate} (${daysUntilShow} days away) +DEAL TYPE: ${props.dealType} +GUARANTEE: ${props.guaranteeAmount ? "$" + props.guaranteeAmount.toLocaleString() : "none"} +PERCENTAGE: ${props.percentage ? (props.percentage * 100).toFixed(0) + "%" : "none"} +EXPENSE CAP: ${props.expenseCap ? "$" + props.expenseCap.toLocaleString() : "none stated"} +HOSPITALITY CAP: ${props.hospitalityCap ? "$" + props.hospitalityCap.toLocaleString() : "none stated"} +TOTAL EXPENSES SO FAR: $${props.totalExpenses.toLocaleString()} +DEAL NOTES (free text): "${props.dealNotes}" + +Generate a JSON pre-settlement brief with this exact structure: +{ + "summary": "One sentence describing the deal and any notable complexity", + "flags": [ + {"severity": "high|medium|low", "text": "specific issue to resolve before show night"} + ], + "expenseReadiness": [ + {"label": "category name", "status": "ready|missing|warning", "note": "short explanation"} + ], + "suggestedAction": "Single most important thing Mariana should do TODAY to prevent a 2am dispute" +} + +Focus on: +- Ambiguous terms in the deal notes that could cause interpretation disputes +- Expense caps that are at risk of being exceeded +- Missing expense categories that typically appear for this deal type +- Any terms referenced as "per deal memo" or "see email" that aren't captured in the system +- The suggestedAction should be specific and actionable, not generic + +Return ONLY the JSON object, no other text.`; + + const response = await fetch("/api/settlement-brief", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt }), + }); + + const data = await response.json(); + const text = data.content?.[0]?.text ?? ""; + const clean = text.replace(/```json|```/g, "").trim(); + const parsed: BriefResult = JSON.parse(clean); + setBrief(parsed); + } catch (e) { + setError("Brief unavailable — check deal notes manually."); + } finally { + setLoading(false); + } + } + + if (!isUpcoming) return null; + + const severityColor = { + high: "text-rose-700 bg-rose-50 ring-rose-200", + medium: "text-amber-700 bg-amber-50 ring-amber-200", + low: "text-blue-700 bg-blue-50 ring-blue-200", + }; + + const severityIcon = { + high: , + medium: , + low: , + }; + + const statusColor = { + ready: "text-brand-700", + missing: "text-rose-600", + warning: "text-amber-600", + }; + + return ( +
+
setExpanded(!expanded)} + > +
+
+ +
+
+
Pre-settlement brief
+
+ {loading ? "Analyzing deal notes..." : brief ? `${brief.flags.length} flag${brief.flags.length === 1 ? "" : "s"} · ${daysUntilShow} days to show` : `${daysUntilShow} days to show`} +
+
+
+ {expanded ? : } +
+ + {expanded && ( +
+ {loading && ( +
+ + Analyzing deal notes for settlement risks... +
+ )} + + {error && ( +
{error}
+ )} + + {brief && ( +
+

{brief.summary}

+ + {brief.flags.length > 0 && ( +
+
Flags
+
+ {brief.flags.map((flag, i) => ( +
+ {severityIcon[flag.severity]} + {flag.text} +
+ ))} +
+
+ )} + + {brief.expenseReadiness.length > 0 && ( +
+
Expense readiness
+
+ {brief.expenseReadiness.map((item, i) => ( +
+
+ {item.status === "ready" + ? + : + } + {item.label} +
+ {item.note} +
+ ))} +
+
+ )} + +
+
Suggested action
+
{brief.suggestedAction}
+
+
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/shows/[id]/page.tsx b/app/shows/[id]/page.tsx index c67e8b91..2e44b690 100644 --- a/app/shows/[id]/page.tsx +++ b/app/shows/[id]/page.tsx @@ -26,6 +26,7 @@ import { relativeShowDate, } from "@/lib/format"; import type { Bonus } from "@/db/schema"; +import { PreSettlementBrief } from "./PreSettlementBrief"; const COMP_LABELS: Record = { artist_gl: "Artist guest list", @@ -139,6 +140,20 @@ export default async function ShowDetailPage({
+ {deal?.dealNotesFreetext && show.status === "advanced" && ( + + )} {show.internalNotes && (
diff --git a/data/greenroom.db b/data/greenroom.db index cc292c7e..1c1d9021 100644 Binary files a/data/greenroom.db and b/data/greenroom.db differ diff --git a/lib/dealMath.ts b/lib/dealMath.ts index 189c04c0..d682b3ca 100644 --- a/lib/dealMath.ts +++ b/lib/dealMath.ts @@ -168,6 +168,89 @@ export function calculateSettlement(input: CalcInput): SettlementCalculation { }; } + // ---------- vs deal (guarantee vs % of net) ---------- + if (deal.dealType === "vs") { + if (deal.guaranteeAmount == null || deal.percentage == null) { + return { + supported: false, + reason: "Vs deal is missing a guarantee amount or percentage.", + dealType: deal.dealType, + }; + } + + const expenseCap = deal.expenseCap ?? totalExpenses; + const cappedExpenses = Math.min(totalExpenses, expenseCap); + const netAfterExpenses = netBoxOffice - cappedExpenses; + const percentagePayout = Math.max(0, netAfterExpenses) * deal.percentage; + const guarantee = deal.guaranteeAmount; + const vsWinner = percentagePayout >= guarantee ? "percentage" : "guarantee"; + const basePayout = Math.max(guarantee, percentagePayout); + + const bonusResult = applyBonuses(parseBonuses(deal), { + gross: grossBoxOffice, + tickets, + capacity: venueCapacity, + }); + + const steps: { label: string; value: number; note?: string }[] = [ + { + label: "Gross box office", + value: grossBoxOffice, + }, + { + label: "Less ticketing fees", + value: -totalFees, + note: "Platform and credit card fees deducted from gross.", + }, + { + label: "Net box office", + value: netBoxOffice, + }, + { + label: `Less expenses${deal.expenseCap != null ? ` (capped at $${deal.expenseCap.toLocaleString()})` : ""}`, + value: -cappedExpenses, + note: deal.expenseCap != null && totalExpenses > deal.expenseCap + ? `Actual expenses $${totalExpenses.toLocaleString()} — capped at $${deal.expenseCap.toLocaleString()}` + : undefined, + }, + { + label: "Net after expenses", + value: netAfterExpenses, + }, + { + label: `× ${(deal.percentage * 100).toFixed(0)}% of net`, + value: percentagePayout, + note: `Percentage payout = $${percentagePayout.toFixed(2)}`, + }, + { + label: `Guarantee floor`, + value: guarantee, + note: `$${guarantee.toLocaleString()} guarantee vs $${percentagePayout.toFixed(2)} percentage — ${vsWinner === "percentage" ? "percentage wins" : "guarantee applies"}`, + }, + ...bonusResult.applied.map((b) => ({ + label: b.label, + value: b.amount, + note: b.reason, + })), + ]; + + const formulaParts = vsWinner === "percentage" + ? `${(deal.percentage * 100).toFixed(0)}% × net $${netAfterExpenses.toFixed(2)} = $${percentagePayout.toFixed(2)} (beats guarantee)` + : `guarantee $${guarantee.toLocaleString()} (percentage $${percentagePayout.toFixed(2)} didn't beat it)`; + + return { + supported: true, + grossBoxOffice, + netBoxOffice, + totalExpenses, + totalToArtist: basePayout + bonusResult.totalApplied, + steps, + finalFormula: formulaParts, + bonusesApplied: bonusResult.applied, + bonusesNotTriggered: bonusResult.notTriggered, + }; + } + // ---------- everything else: not supported ---------- const friendlyName: Record = { flat: "Flat guarantee", @@ -185,7 +268,6 @@ export function calculateSettlement(input: CalcInput): SettlementCalculation { `Power users at venues like The Crescent default to spreadsheets for these.`, }; } - /** Evaluate a list of bonuses against the show's actual numbers. */ function applyBonuses( bonuses: Bonus[], @@ -241,10 +323,6 @@ function applyBonuses( }); } } else if (b.type === "tier_ratchet") { - // Tier ratchets fundamentally change the percentage structure. The - // current engine only supports flat % of gross — we can't apply a - // ratcheting structure on top of it without knowing which deal type - // it's modifying. Report as not-applicable. notTriggered.push({ label: b.label, amount: 0, diff --git a/package-lock.json b/package-lock.json index 91d331b6..320cb96e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2598,17 +2598,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",