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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.env.local
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions app/api/settlement-brief/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
198 changes: 198 additions & 0 deletions app/shows/[id]/PreSettlementBrief.tsx
Original file line number Diff line number Diff line change
@@ -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<BriefResult | null>(null);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState(true);
const [error, setError] = useState<string | null>(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: <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />,
medium: <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />,
low: <Lightbulb className="h-3.5 w-3.5 shrink-0 mt-0.5" />,
};

const statusColor = {
ready: "text-brand-700",
missing: "text-rose-600",
warning: "text-amber-600",
};

return (
<div className="mb-8 rounded-lg ring-1 ring-ink-200/60 bg-white overflow-hidden">
<div
className="flex items-center justify-between px-5 py-4 cursor-pointer hover:bg-canvas-soft transition-colors"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-2.5">
<div className="h-6 w-6 rounded-full bg-brand-700 flex items-center justify-center">
<Lightbulb className="h-3.5 w-3.5 text-white" />
</div>
<div>
<div className="text-[13px] font-semibold text-ink-900">Pre-settlement brief</div>
<div className="text-[11.5px] text-ink-400">
{loading ? "Analyzing deal notes..." : brief ? `${brief.flags.length} flag${brief.flags.length === 1 ? "" : "s"} · ${daysUntilShow} days to show` : `${daysUntilShow} days to show`}
</div>
</div>
</div>
{expanded ? <ChevronUp className="h-4 w-4 text-ink-400" /> : <ChevronDown className="h-4 w-4 text-ink-400" />}
</div>

{expanded && (
<div className="px-5 pb-5 border-t border-ink-100">
{loading && (
<div className="flex items-center gap-2 py-6 text-ink-400">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-[13px]">Analyzing deal notes for settlement risks...</span>
</div>
)}

{error && (
<div className="text-[12.5px] text-ink-400 py-4">{error}</div>
)}

{brief && (
<div className="space-y-5 pt-4">
<p className="text-[13px] text-ink-600 leading-relaxed">{brief.summary}</p>

{brief.flags.length > 0 && (
<div>
<div className="text-[10px] font-semibold text-ink-400 uppercase tracking-wider mb-2.5">Flags</div>
<div className="space-y-2">
{brief.flags.map((flag, i) => (
<div key={i} className={`flex items-start gap-2 text-[12.5px] px-3 py-2.5 rounded-md ring-1 ${severityColor[flag.severity]}`}>
{severityIcon[flag.severity]}
<span>{flag.text}</span>
</div>
))}
</div>
</div>
)}

{brief.expenseReadiness.length > 0 && (
<div>
<div className="text-[10px] font-semibold text-ink-400 uppercase tracking-wider mb-2.5">Expense readiness</div>
<div className="space-y-1.5">
{brief.expenseReadiness.map((item, i) => (
<div key={i} className="flex items-start justify-between gap-3 text-[12.5px]">
<div className="flex items-center gap-1.5">
{item.status === "ready"
? <CheckCircle className="h-3.5 w-3.5 text-brand-600 shrink-0" />
: <AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${item.status === "missing" ? "text-rose-500" : "text-amber-500"}`} />
}
<span className="text-ink-700">{item.label}</span>
</div>
<span className={`text-right text-[11.5px] ${statusColor[item.status]}`}>{item.note}</span>
</div>
))}
</div>
</div>
)}

<div className="rounded-md bg-brand-50 ring-1 ring-brand-200/60 px-4 py-3">
<div className="text-[10px] font-semibold text-brand-700 uppercase tracking-wider mb-1">Suggested action</div>
<div className="text-[13px] text-ink-800 leading-relaxed">{brief.suggestedAction}</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
15 changes: 15 additions & 0 deletions app/shows/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
relativeShowDate,
} from "@/lib/format";
import type { Bonus } from "@/db/schema";
import { PreSettlementBrief } from "./PreSettlementBrief";

const COMP_LABELS: Record<string, string> = {
artist_gl: "Artist guest list",
Expand Down Expand Up @@ -139,6 +140,20 @@ export default async function ShowDetailPage({
</div>

<div className="px-12 pb-12">
{deal?.dealNotesFreetext && show.status === "advanced" && (
<PreSettlementBrief
showId={show.id}
dealNotes={deal.dealNotesFreetext}
dealType={deal.dealType}
guaranteeAmount={deal.guaranteeAmount ?? null}
percentage={deal.percentage ?? null}
expenseCap={deal.expenseCap ?? null}
hospitalityCap={deal.hospitalityCap ?? null}
totalExpenses={totalExpenses}
showDate={show.date}
artistName={artist?.name ?? "Unknown Artist"}
/>
)}
{show.internalNotes && (
<div className="mb-8 mt-1 rounded-lg bg-amber-50/50 ring-1 ring-amber-200/60 p-5 flex gap-3">
<AlertCircle className="h-4 w-4 text-amber-700 mt-0.5 shrink-0" />
Expand Down
Binary file modified data/greenroom.db
Binary file not shown.
88 changes: 83 additions & 5 deletions lib/dealMath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Deal["dealType"], string> = {
flat: "Flat guarantee",
Expand All @@ -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[],
Expand Down Expand Up @@ -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,
Expand Down
Loading