/* ============================================================ RESTO SUITE — FINANCIAL FEASIBILITY MODULE Reads/writes: plan.financials (its own namespace only) Reads: plan.idea (to pre-fill seats / avg check hints) Reuses: InvCategory, SpaceCard, INV_SUGGEST, COST_SUGGEST (investment-details.jsx), PageSummary, FinancialSummary (financial-summary.jsx), primitives (suite-shared.jsx) ============================================================ */ const { useState: useStateF, useMemo: useMemoF } = React; function computeFin(state, t) { const invVals = {}; for (const k in state.investment) { const its = (state.invItems || {})[k] || []; invVals[k] = its.length ? its.reduce((a, i) => a + (i.qty || 0) * (i.price || 0), 0) : state.investment[k]; } const investment = Object.values(invVals).reduce((a, b) => a + b, 0); let sales; if (state.revMode === "detailed") { const d = state.detailed; sales = d.seats * d.turns * d.avgCheck * d.openDays; } else sales = state.monthlySales; const foodPct = state.foodPct; const foodCost = sales * foodPct; const costVal = (k) => { const its = (state.costItems || {})[k] || []; return its.length ? its.reduce((a, i) => a + (i.qty || 0) * (i.price || 0), 0) : state[k]; }; const costs = { staff: costVal("staff"), rent: costVal("rent"), utilities: costVal("utilities"), marketing: costVal("marketing"), management: costVal("management") || 0, other: costVal("other"), taxes: costVal("taxes") || 0, }; const fixed = costs.staff + costs.rent + costs.utilities + costs.marketing + costs.management + costs.other; const taxTotal = costs.taxes || 0; const totalCost = foodCost + fixed + taxTotal; const profit = sales - (foodCost + fixed); // pre-tax operating profit (EBITDA) const margin = sales > 0 ? profit / sales : 0; const cmRatio = 1 - foodPct; const breakEven = cmRatio > 0 ? fixed / cmRatio : Infinity; const openDays = state.revMode === "detailed" ? state.detailed.openDays : 26; const breakEvenDaily = breakEven / openDays; const payback = profit > 0 ? investment / profit : Infinity; /* ---------- taxes: now a single manual line inside Operating Cost ---------- */ const tax = { ...((FIN_DEFAULTS && FIN_DEFAULTS.tax) || {}), ...(state.tax || {}) }; const annualGross = sales * 12; const netSales = sales, outputVat = 0, inputVat = 0, netVat = 0, vatApplies = false; const ssoHeads = 0, sso = 0, cit = 0; const profitBeforeTax = profit; const annualPBT = profit * 12; const netProfit = sales - totalCost; // take-home after the taxes line const netMargin = sales > 0 ? netProfit / sales : 0; const paybackAfterTax = netProfit > 0 ? investment / netProfit : Infinity; const share = (v) => (sales > 0 ? v / sales : 0); const segs = [ { key: "food", label: t("fin.segFood"), value: foodCost, pct: share(foodCost), color: "var(--c-food)" }, { key: "staff", label: t("fin.segStaff"), value: costs.staff, pct: share(costs.staff), color: "var(--c-staff)" }, { key: "rent", label: t("fin.segRent"), value: costs.rent, pct: share(costs.rent), color: "var(--c-rent)" }, { key: "utilities", label: t("fin.segUtil"), value: costs.utilities, pct: share(costs.utilities), color: "var(--c-util)" }, { key: "marketing", label: t("fin.segMkt"), value: costs.marketing, pct: share(costs.marketing), color: "var(--c-mkt)" }, { key: "management", label: t("fin.segMgmt"), value: costs.management, pct: share(costs.management), color: "var(--c-mgmt)" }, { key: "other", label: t("fin.segOther"), value: costs.other, pct: share(costs.other), color: "var(--c-other)" }, ...(taxTotal > 0 ? [{ key: "tax", label: t("fin.segTax"), value: taxTotal, pct: share(taxTotal), color: "var(--c-tax)" }] : []), ]; let verdict; if (sales <= 0) verdict = { key: "none", label: t("fin.verdictNone"), tone: "muted" }; else if (netProfit <= 0) verdict = { key: "loss", label: t("fin.verdictLoss"), tone: "red" }; else if (netMargin < 0.05) verdict = { key: "risky", label: t("fin.verdictRisky"), tone: "amber" }; else if (netMargin < 0.12) verdict = { key: "ok", label: t("fin.verdictOk"), tone: "amber" }; else if (netMargin < 0.20) verdict = { key: "healthy", label: t("fin.verdictHealthy"), tone: "green" }; else verdict = { key: "strong", label: t("fin.verdictStrong"), tone: "green" }; const staffPct = share(costs.staff), rentPct = share(costs.rent), primePct = foodPct + staffPct; const flags = []; if (sales > 0) { if (foodPct > 0.38) flags.push({ tone: "amber", text: t("idea.flagFoodHigh", { pct: fmtPct(foodPct) }) }); else if (foodPct < 0.20) flags.push({ tone: "amber", text: t("idea.flagFoodLow", { pct: fmtPct(foodPct) }) }); else flags.push({ tone: "green", text: t("idea.flagFoodHealthy", { pct: fmtPct(foodPct) }) }); if (staffPct > 0.40) flags.push({ tone: "amber", text: t("idea.flagStaffHigh", { pct: fmtPct(staffPct) }) }); if (primePct > 0.65) flags.push({ tone: "red", text: t("idea.flagPrimeHigh", { pct: fmtPct(primePct) }) }); if (rentPct > 0.15) flags.push({ tone: "amber", text: t("idea.flagRentHigh", { pct: fmtPct(rentPct) }) }); if (profit > 0 && margin >= 0.12) flags.push({ tone: "green", text: t("idea.flagMarginGood", { pct: fmtPct(margin) }) }); if (profit <= 0) flags.push({ tone: "red", text: t("idea.flagLoss") }); if (isFinite(payback) && payback > 48) flags.push({ tone: "amber", text: t("idea.flagPaybackSlow", { months: fmtMonths(payback) }) }); if (taxTotal === 0 && profit > 0) flags.push({ tone: "amber", text: t("idea.flagNoTax") }); } const capex = invVals.renovation + invVals.equipment + invVals.furniture + invVals.licenses + (invVals.management || 0); const shopSqm = (state.space || {}).shopSqm || 0; const kitchenSqm = (state.space || {}).kitchenSqm || 0; const capexPerSqm = shopSqm > 0 ? capex / shopSqm : NaN; const kitchenPerSqm = kitchenSqm > 0 ? invVals.equipment / kitchenSqm : NaN; const reserveMonths = fixed > 0 ? invVals.reserve / fixed : NaN; const detailedMode = state.revMode === "detailed"; const custDay = detailedMode ? state.detailed.seats * state.detailed.turns : NaN; const avgCheck = detailedMode ? state.detailed.avgCheck : NaN; return { investment, invVals, costs, sales, foodCost, fixed, totalCost, profit, margin, breakEven, breakEvenDaily, payback, segs, verdict, flags, staffPct, rentPct, primePct, foodPct, salesDaily: sales / openDays, capex, capexPerSqm, kitchenPerSqm, reserveMonths, detailedMode, custDay, avgCheck, tax, annualGross, vatApplies, netSales, outputVat, inputVat, netVat, ssoHeads, sso, profitBeforeTax, annualPBT, cit, taxTotal, netProfit, netMargin, paybackAfterTax, }; } /* ---------- steps ---------- */ const INV_FIELDS = [ ["renovation", "invRenoLabel", "invRenoHint"], ["equipment", "invEquipLabel", "invEquipHint"], ["furniture", "invFurnLabel", "invFurnHint"], ["deposit", "invDepositLabel", "invDepositHint"], ["licenses", "invLicLabel", "invLicHint"], ["management", "invMgmtLabel", "invMgmtHint"], ["inventory", "invInvLabel", "invInvHint"], ["reserve", "invReserveLabel", "invReserveHint"], ]; const COST_FIELDS = [ ["staff", "costStaffLabel", "costStaffHint"], ["rent", "costRentLabel", "costRentHint"], ["utilities", "costUtilLabel", "costUtilHint"], ["marketing", "costMktLabel", "costMktHint"], ["management", "costMgmtLabel", "costMgmtHint"], ["other", "costOtherLabel", "costOtherHint"], ["taxes", "costTaxLabel", "costTaxHint"], ]; function FinStepInvestment({ state, set, live, summary }) { const { t, lang } = useLang(); return (
{INV_FIELDS.map(([k, labelKey, hintKey]) => ( set({ investment: { ...state.investment, [k]: v } })} onItems={(items) => set({ invItems: { ...state.invItems, [k]: items } })} suggestions={(INV_SUGGEST[k] && INV_SUGGEST[k][lang]) || []} /> ))}
set({ space: { ...state.space, ...p } })} /> {t("fin.reserveTip")} {summary && ( 0 ? live.reserveMonths.toFixed(1) + " mo" : "—", sub: t("fin.ofFixedCosts") }, { label: t("fin.capitalRequired"), value: money(live.investment), sub: t("fin.everythingUpfront") }, ]} /> )}
); } function FinStepSales({ state, set, live, summary, idea }) { const { t } = useLang(); const d = state.detailed; const ideaSeats = idea && idea.format && idea.format.seats > 0 ? idea.format.seats : null; return ( set({ revMode: v })} options={[{ v: "simple", l: t("fin.haveTarget") }, { v: "detailed", l: t("fin.helpEstimate") }]} /> {state.revMode === "simple" ? (
{t("fin.expectedMonthlySales")}
{t("fin.monthlySalesDesc")}
set({ monthlySales: v })} />
{t("fin.aboutADay", { x: fmtBaht(live.salesDaily) })}
) : (
{ideaSeats && ideaSeats !== d.seats && (
{t("fin.briefSaysSeats", { n: ideaSeats })}
)} set({ detailed: { ...d, seats: v } })} step={2} /> set({ detailed: { ...d, turns: v } })} step={0.1} suffix="×" /> set({ detailed: { ...d, avgCheck: v } })} /> set({ detailed: { ...d, openDays: v } })} max={31} suffix="days" />
{t("fin.estMonthlySales")}
{t("fin.perDayApprox", { x: fmtBaht(live.salesDaily) })}
{fmtBaht(live.sales)}
)} {summary && ( )}
); } function FinStepCosts({ state, set, live, summary }) { const { t, lang } = useLang(); const share = (v) => (live.sales > 0 && v > 0 ? t("fin.ofSalesShare", { pct: fmtPct(v / live.sales) }) : null); return (
{t("fin.operatingCost")}
{t("fin.operatingCostDesc")}
{t("fin.foodDrink")}{t("fin.foodDrinkSub", { sales: fmtBaht(live.sales), food: fmtBaht(live.foodCost) })}
set({ foodPct: v })} />
{COST_FIELDS.map(([k, labelKey, hintKey]) => ( set({ [k]: v })} onItems={(items) => set({ costItems: { ...state.costItems, [k]: items } })} suggestions={(COST_SUGGEST[k] && COST_SUGGEST[k][lang]) || []} meta={share(live.costs[k])} /> ))}
= 0 ? "green" : "red", "bg"), border: `1px solid ${toneVar(live.netProfit >= 0 ? "green" : "red", "bd")}`, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12, flexWrap: "wrap" }}> {live.netProfit >= 0 ? t("fin.takeHomeAfterTax") : t("fin.shortfallAfterTax")} {t("fin.beforeTax", { x: (live.profitBeforeTax < 0 ? "−" : "") + fmtBaht(Math.abs(live.profitBeforeTax)) })} = 0 ? "var(--green)" : "var(--red)" }}>{live.netProfit < 0 ? "−" : ""}{fmtBaht(Math.abs(live.netProfit))}
{summary && ( 0 ? `${live.profit < 0 ? "−" : ""}${fmtBaht(Math.abs(live.profit))}` : "—", sub: live.sales > 0 ? t("fin.marginPreTax", { pct: fmtPct(live.margin) }) : null }, { label: t("fin.netProfitAfterTax"), value: live.sales > 0 ? `${live.netProfit < 0 ? "−" : ""}${fmtBaht(Math.abs(live.netProfit))}` : "—", sub: live.sales > 0 ? t("fin.afterTaxMargin", { pct: fmtPct(live.netMargin) }) : null, tone: live.netProfit >= 0 ? "green" : "red" }, ]} /> )}
); } /* ---------- results viz ---------- */ function StackedBar({ live }) { const segs = live.segs.filter((s) => s.pct > 0.001); const profitPct = Math.max(0, live.sales > 0 ? live.netProfit / live.sales : 0); const isLoss = live.netProfit < 0; return (
{segs.map((s) => (
))} {!isLoss && profitPct > 0 &&
}
); } function Donut({ live }) { const { t } = useLang(); const segs = live.segs.filter((s) => s.pct > 0.001); const profitShare = live.sales > 0 ? live.netProfit / live.sales : 0; const all = [...segs, ...(profitShare > 0 ? [{ key: "profit", label: t("fin.profitKeep"), pct: profitShare, color: "var(--c-profit)" }] : [])]; const total = all.reduce((a, b) => a + b.pct, 0) || 1; const R = 84, C = 2 * Math.PI * R; let acc = 0; return (
{all.map((s) => { const frac = s.pct / total, el = ( ); acc += frac; return el; })}
{t("fin.netMarginDonut")}
{fmtPct(live.netMargin)}
); } function BreakdownList({ live }) { const { t } = useLang(); const Row = ({ color, label, pct, value, bold, tone }) => { const vc = tone === "green" ? "var(--green)" : tone === "red" ? "var(--red)" : "var(--ink)"; return (
{label} {fmtPct(pct)} {value < 0 ? "−" : ""}฿{fmt(Math.abs(value))}
); }; return (
{live.segs.map((s) => )} = 0 ? t("fin.profitKeep") : t("fin.monthlyLoss")} pct={live.sales > 0 ? live.netProfit / live.sales : 0} value={live.netProfit} bold tone={live.netProfit >= 0 ? "green" : "red"} />
); } function finVerdictBlurb(live, t) { if (live.sales <= 0) return t("fin.blurbEmpty"); if (live.netProfit <= 0) return t("fin.blurbLoss", { x: fmtBaht(Math.abs(live.netProfit)) }); if (live.netMargin < 0.12) return t("fin.blurbThin"); return t("fin.blurbGood", { be: fmtBaht(live.breakEven), pb: fmtMonths(live.paybackAfterTax) }); } function FinStepResults({ state, set, live, narrow, showSummary, coaching }) { const { t } = useLang(); const v = live.verdict, tone = v.tone; return (
{t("fin.theVerdict")}
{v.label}
{finVerdictBlurb(live, t)}
{t("fin.netProfitMo")}
{live.netProfit < 0 ? "−" : ""}{fmtBaht(Math.abs(live.netProfit))}
{t("fin.preTaxTaxesEst", { x: (live.profit < 0 ? "−" : "") + fmtBaht(Math.abs(live.profit)), y: fmtBaht(live.taxTotal) })}
= 0.12 ? "green" : live.netMargin > 0 ? "amber" : "red"} />

{t("fin.whereEvery100")}

{t("fin.every100Desc")}
set({ chart: c })} options={[{ v: "bar", l: t("fin.bar") }, { v: "donut", l: t("fin.donut") }]} />
{state.chart === "donut" ? (
) : (
)}
{showSummary && } {coaching && live.flags.length > 0 && (

{t("fin.whatToWatch")}

{live.flags.map((f, i) => )}
)}
); } /* ---------- module wrapper ---------- */ function FinancialModule({ fin, setFin, idea, narrow, summary, coaching }) { const { t } = useLang(); const FIN_STEPS = [t("fin.stepInvestment"), t("fin.stepSales"), t("fin.stepCosts"), t("fin.stepResults")]; const set = (patch) => setFin(patch); const live = useMemoF(() => computeFin(fin, t), [fin, t]); const step = fin.step || 0; const setStep = (i) => set({ step: i }); return (
{FIN_STEPS.map((s, i) => ( ))}
{step === 0 && } {step === 1 && } {step === 2 && } {step === 3 && }
{step < 3 && }
); } Object.assign(window, { computeFin, FinancialModule });