483 lines
24 KiB
TypeScript
483 lines
24 KiB
TypeScript
import { ReactNode, useEffect, useState } from "react";
|
||
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||
import { ArrowDownRight, ArrowUpRight, CloudDownload, Pencil, Save, CalendarRange,
|
||
RotateCcw, CornerDownRight, Loader2 } from "lucide-react";
|
||
import { api, OpeningBalanceT } from "../../api/client";
|
||
import { money, acct, num } from "../../lib/format";
|
||
import { InfoTip, Section, EmptyState, StatusBadge, useDefinitions } from "../../components/ui";
|
||
import { AllMarketsTable, MarketTabs, isAll, useMarket, ALL_MARKETS } from "../../components/market";
|
||
import BankReceipts from "../../components/BankReceipts";
|
||
import { useClosing } from "../Closing";
|
||
|
||
type Gran = "day" | "week" | "month";
|
||
|
||
export default function ArLedger() {
|
||
const { id, processed } = useClosing();
|
||
const qc = useQueryClient();
|
||
const defs = useDefinitions();
|
||
const [sel, setSel] = useMarket();
|
||
const showAll = isAll(sel);
|
||
const mktParam = showAll ? undefined : sel;
|
||
|
||
const [gran, setGran] = useState<Gran>("day");
|
||
const [from, setFrom] = useState("");
|
||
const [to, setTo] = useState("");
|
||
|
||
const { data: mv } = useQuery({
|
||
queryKey: ["ar-movement", id, mktParam],
|
||
queryFn: () => api.arMovement(id, mktParam),
|
||
enabled: processed,
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
const { data: openings } = useQuery({
|
||
queryKey: ["openings", id], queryFn: () => api.openings(id), enabled: processed,
|
||
});
|
||
const { data: detail } = useQuery({
|
||
queryKey: ["ledger-detail", id, mktParam, gran, from, to],
|
||
queryFn: () => api.ledgerDetail(id, mktParam, gran, from || undefined, to || undefined),
|
||
enabled: processed && !showAll,
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
const { data: fx } = useQuery({
|
||
queryKey: ["fx-daily", id, mktParam, from, to],
|
||
queryFn: () => api.fxDaily(id, mktParam, from || undefined, to || undefined),
|
||
enabled: processed && !showAll,
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
const { data: all } = useQuery({
|
||
queryKey: ["all-markets", id], queryFn: () => api.allMarkets(id), enabled: processed && showAll,
|
||
});
|
||
|
||
if (!processed) return <EmptyState title="Process the closing to see the AR ledger." />;
|
||
if (!mv || !mv.available) return <EmptyState title="AR movement not available yet." />;
|
||
|
||
const mkt = mv.marketplace ?? "USA";
|
||
const cur = mv.currency ?? "USD";
|
||
const m = (v: number | null | undefined, dp = 0) => money(v, cur, dp);
|
||
// USD-reporting marketplaces would just repeat every figure — show the pair only when
|
||
// the local currency actually differs.
|
||
const dual = cur !== "USD";
|
||
const inUsd = (v: number | null | undefined, dp = 2) =>
|
||
dual && v != null ? (
|
||
<div className="text-[11px] leading-tight text-subink">{money(v, "USD", dp)}</div>
|
||
) : null;
|
||
const opening = openings?.find((o) => o.marketplace === mkt);
|
||
const diff = mv.difference_vs_settlement ?? 0;
|
||
const reconciled = Math.abs(diff) < 1;
|
||
const multi = (mv.marketplaces?.length ?? 0) > 1;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{multi && (
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<MarketTabs markets={mv.marketplaces} value={showAll ? ALL_MARKETS : mkt} onChange={setSel} />
|
||
<span className="text-xs text-subink">
|
||
{showAll ? "Every marketplace with the combined total."
|
||
: <>Opening balance, revenue and payouts below are for <b>{mkt}</b> only.</>}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{showAll ? (
|
||
<AllMarketsTable data={all} onView={setSel} title="All markets — receivable movement" />
|
||
) : (
|
||
<>
|
||
<OpeningEditor id={id} mkt={mkt} cur={cur} opening={opening}
|
||
onSaved={() => {
|
||
qc.invalidateQueries({ queryKey: ["openings", id] });
|
||
qc.invalidateQueries({ queryKey: ["ar-movement", id] });
|
||
qc.invalidateQueries({ queryKey: ["ledger-detail", id] });
|
||
}} />
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||
<div className="lg:col-span-2">
|
||
<Section title="Closing receivable" subtitle="Opening + Net Revenue − Payouts received"
|
||
actions={<InfoTip def={defs.closing_receivable} label="Closing receivable (roll-forward)" />}>
|
||
<div className="p-4 space-y-2 text-sm">
|
||
<Row label={<>Opening AR balance<InfoTip def={defs.opening_balance} label="Opening AR balance" /></>}
|
||
v={mv.opening!} cur={cur} />
|
||
<Row label={<>+ Net revenue (accrued)<InfoTip def={defs.net_revenue} label="Net revenue" /></>}
|
||
v={mv.net_revenue!} cur={cur} pos />
|
||
<div className="border-t border-line my-1" />
|
||
<Row label="= Total Amazon receivable" v={mv.total_receivable!} cur={cur} bold />
|
||
<Row label={<>− Amazon payouts received<InfoTip def={defs.disbursements} label="Payouts received" /></>}
|
||
v={mv.received_payouts!} cur={cur} />
|
||
<div className="border-t border-line my-1" />
|
||
<div className="flex items-center justify-between pt-1">
|
||
<span className="font-semibold text-primary">= Closing receivable</span>
|
||
<span className="num text-lg font-semibold text-primary">{m(mv.closing)}</span>
|
||
</div>
|
||
{dual && detail?.month_rate != null && (
|
||
<div className="flex items-center justify-between text-xs text-subink">
|
||
<span>in USD @ month rate {num(detail.month_rate, 6)}</span>
|
||
<span className="num font-medium">
|
||
{money((mv.closing ?? 0) * detail.month_rate, "USD", 2)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-subink pt-2">
|
||
In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain
|
||
in receivable (not yet cleared).
|
||
<InfoTip def={defs.in_transit_payouts} label="In-transit payouts" />
|
||
</p>
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
|
||
<div className="lg:col-span-3">
|
||
<Section title="AR Ledger"
|
||
subtitle="Monthly receivable movement — the summary report is generated from this ledger.">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead><tr>
|
||
<th className="th">Period</th><th className="th">Description</th>
|
||
<th className="th text-right">Debit</th><th className="th text-right">Credit</th>
|
||
<th className="th text-right">Running AR</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
{(mv.ledger ?? []).map((r, i) => {
|
||
const key = r.period === "Opening" || r.period === "Closing";
|
||
return (
|
||
<tr key={i} className={key ? "bg-neutralbg/50 font-medium" : ""}>
|
||
<td className="td">{r.period}</td>
|
||
<td className="td text-subink text-xs">
|
||
<span className="inline-flex items-center gap-1">
|
||
{r.debit != null && <ArrowUpRight size={12} className="text-primary" />}
|
||
{r.credit != null && <ArrowDownRight size={12} className="text-bad" />}
|
||
{r.description}
|
||
</span>
|
||
</td>
|
||
<td className="td text-right num">{r.debit != null ? acct(r.debit) : ""}</td>
|
||
<td className="td text-right num">{r.credit != null ? acct(r.credit) : ""}</td>
|
||
<td className="td text-right num font-medium">{m(r.balance)}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bank receipts: when each payout actually reached the bank (drives received
|
||
vs in-transit and the Movement-by-date placement below). */}
|
||
<BankReceipts id={id} marketplace={mkt} />
|
||
|
||
<div className={`card p-4 flex flex-wrap items-center gap-4 ${reconciled ? "bg-okbg/40" : "bg-warnbg/40"}`}>
|
||
<StatusBadge status={reconciled ? "reconciled" : "review"} />
|
||
<div className="text-sm">
|
||
<b>Cross-check vs settlement method:</b> roll-forward closing {m(mv.closing)} vs settlement-based{" "}
|
||
{m(mv.settlement_closing ?? undefined)} —{" "}
|
||
<span className="num">{reconciled ? "match" : `difference ${m(diff, 2)}`}</span>.
|
||
</div>
|
||
{!reconciled && (
|
||
<p className="text-xs text-subink flex-1 min-w-[200px]">
|
||
A difference usually means the opening balance or a reserve/timing item needs review.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* ---------------- date-filtered movement ---------------- */}
|
||
<Section title="Movement by date"
|
||
subtitle={`Daily, weekly or monthly view of the same ledger. Unfiltered, the running balance ends at the closing receivable.${
|
||
dual ? " USD figures (grey) are converted at each transaction date's exchange rate." : ""}`}>
|
||
<div className="p-4 flex flex-wrap items-end gap-3 border-b border-line">
|
||
<div className="flex gap-1 p-1 rounded-xl bg-neutralbg">
|
||
{(["day", "week", "month"] as Gran[]).map((g) => (
|
||
<button key={g} onClick={() => setGran(g)}
|
||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||
gran === g ? "bg-panel text-primary shadow-card" : "text-subink hover:text-ink"
|
||
}`}>{g === "day" ? "Daily" : g === "week" ? "Weekly" : "Monthly"}</button>
|
||
))}
|
||
</div>
|
||
<div className="flex items-end gap-2">
|
||
<div>
|
||
<label className="label mb-1">From</label>
|
||
<input type="date" className="input w-40 py-1.5" value={from}
|
||
onChange={(e) => setFrom(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="label mb-1">To</label>
|
||
<input type="date" className="input w-40 py-1.5" value={to}
|
||
onChange={(e) => setTo(e.target.value)} />
|
||
</div>
|
||
{(from || to) && (
|
||
<button className="btn-ghost py-1.5" onClick={() => { setFrom(""); setTo(""); }}>
|
||
Clear
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="flex-1" />
|
||
<span className="text-xs text-subink inline-flex items-center gap-1">
|
||
<CalendarRange size={13} />
|
||
{detail?.filtered ? "Custom range — balance covers the filtered dates only" : "Full month"}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="overflow-x-auto max-h-[460px]">
|
||
<table className="w-full">
|
||
<thead><tr>
|
||
<th className="th">Period</th>
|
||
<th className="th text-right">Rows</th>
|
||
<th className="th text-right">Net revenue</th>
|
||
<th className="th text-right">Payouts received</th>
|
||
<th className="th text-right">In transit</th>
|
||
<th className="th text-right">Running AR</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<tr className="bg-neutralbg/50 font-medium">
|
||
<td className="td">Opening</td>
|
||
<td className="td" /><td className="td" /><td className="td" /><td className="td" />
|
||
<td className="td text-right num">
|
||
{m(detail?.opening)}
|
||
{inUsd(detail?.opening_usd)}
|
||
</td>
|
||
</tr>
|
||
{(detail?.periods ?? []).map((p) => (
|
||
<tr key={p.key}>
|
||
<td className="td">{p.label}</td>
|
||
<td className="td text-right num text-xs text-subink">{p.rows.toLocaleString()}</td>
|
||
<td className="td text-right num">
|
||
{acct(p.revenue)}
|
||
{inUsd(p.revenue_usd)}
|
||
</td>
|
||
<td className="td text-right num text-bad">
|
||
{p.payouts_received ? acct(p.payouts_received) : ""}
|
||
{p.payouts_received ? inUsd(p.payouts_received_usd) : null}
|
||
</td>
|
||
<td className="td text-right num text-warn">
|
||
{p.payouts_in_transit ? acct(p.payouts_in_transit) : ""}
|
||
{p.payouts_in_transit ? inUsd(p.payouts_in_transit_usd) : null}
|
||
</td>
|
||
<td className="td text-right num font-medium">
|
||
{m(p.balance)}
|
||
{inUsd(p.balance_usd)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
<tr className="bg-primary-soft/50 font-semibold">
|
||
<td className="td text-primary">Closing</td>
|
||
<td className="td" /><td className="td" /><td className="td" /><td className="td" />
|
||
<td className="td text-right num text-primary">
|
||
{m(detail?.closing)}
|
||
{inUsd(detail?.closing_usd)}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{detail && !detail.filtered && (
|
||
<p className="px-4 py-3 text-xs text-subink border-t border-line">
|
||
Ends at {m(detail.closing)} — matches the closing receivable above. In-transit payouts of{" "}
|
||
{m(detail.in_transit_total)} are listed separately and stay inside the balance.
|
||
</p>
|
||
)}
|
||
</Section>
|
||
|
||
{/* ---------------- daily FX ---------------- */}
|
||
<Section title={`Daily exchange rates — ${mkt}`}
|
||
subtitle="Local value per day, the USD rate applied, and the USD equivalent."
|
||
actions={cur !== "USD" ? <FetchDailyRates id={id} mkt={mkt} /> : undefined}>
|
||
{cur === "USD" && (
|
||
<div className="px-4 pt-3 text-xs text-subink">
|
||
{mkt} reports in USD — no conversion applied (rate 1.000000).
|
||
</div>
|
||
)}
|
||
<div className="overflow-x-auto max-h-[420px]">
|
||
<table className="w-full">
|
||
<thead><tr>
|
||
<th className="th">Date</th>
|
||
<th className="th text-right">Local ({fx?.currency ?? cur})</th>
|
||
<th className="th text-right">USD rate</th>
|
||
<th className="th text-right">USD value</th>
|
||
<th className="th">Rate source</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
{(fx?.rows ?? []).map((r) => (
|
||
<tr key={r.date}>
|
||
<td className="td num text-xs">{r.date}</td>
|
||
<td className="td text-right num">{money(r.local, fx?.currency ?? cur, 2)}</td>
|
||
<td className="td text-right num text-xs">{num(r.rate, 6)}</td>
|
||
<td className="td text-right num">{money(r.usd, "USD", 2)}</td>
|
||
<td className="td text-xs text-subink">{r.source}</td>
|
||
</tr>
|
||
))}
|
||
<tr className="bg-neutralbg/50 font-semibold">
|
||
<td className="td">Total</td>
|
||
<td className="td text-right num">{money(fx?.total_local, fx?.currency ?? cur, 2)}</td>
|
||
<td className="td" />
|
||
<td className="td text-right num">{money(fx?.total_usd, "USD", 2)}</td>
|
||
<td className="td" />
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p className="px-4 py-3 text-xs text-subink border-t border-line">
|
||
Daily rates are fetched from the FX provider automatically when the closing is
|
||
processed; each movement converts at the rate effective on its transaction date — a
|
||
date without a fixing (weekend or holiday) uses the previous banking day's rate.
|
||
The month rate ({num(fx?.month_rate, 6)}) applies to the opening balance and any date
|
||
with no fetched rate. Every rate used is shown here so the conversion is auditable.
|
||
</p>
|
||
</Section>
|
||
|
||
<p className="text-xs text-subink">
|
||
Gross revenue {m(mv.gross_revenue, 2)} → net revenue {m(mv.net_revenue, 2)} after refunds and
|
||
Amazon fees. See the <b>Journal Entry</b> tab for the full line-by-line breakdown.
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Re-fetch the official (ECB via Frankfurter) daily rates for the closing's transaction
|
||
* dates. Processing already fetches them automatically — this button retries after an
|
||
* outage or replaces hand-entered overrides with official fixings. */
|
||
function FetchDailyRates({ id, mkt }: { id: number; mkt: string }) {
|
||
const qc = useQueryClient();
|
||
const { locked } = useClosing();
|
||
const fetchDaily = useMutation({
|
||
mutationFn: () => api.fetchFxDaily(id, { marketplace: mkt }),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ["fx-daily", id] }),
|
||
});
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
{fetchDaily.isSuccess && (
|
||
<span className="text-xs text-ok">
|
||
{fetchDaily.data.saved} daily rate(s) loaded ({fetchDaily.data.provider}).
|
||
</span>
|
||
)}
|
||
{fetchDaily.isError && (
|
||
<span className="text-xs text-bad">{(fetchDaily.error as Error).message}</span>
|
||
)}
|
||
<button className="btn-ghost" disabled={fetchDaily.isPending || locked}
|
||
title="Re-fetch the official daily rates for the closing's transaction dates. Hand-entered overrides are replaced for the fetched dates."
|
||
onClick={() => fetchDaily.mutate()}>
|
||
{fetchDaily.isPending ? <Loader2 size={15} className="animate-spin" /> : <CloudDownload size={15} />}
|
||
Fetch daily rates
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Row({ label, v, cur, bold, pos }: {
|
||
label: ReactNode; v: number; cur: string; bold?: boolean; pos?: boolean;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between">
|
||
<span className={bold ? "font-semibold" : "text-subink"}>{label}</span>
|
||
<span className={`num ${bold ? "font-semibold" : ""} ${v < 0 ? "text-bad" : pos ? "text-ok" : "text-ink"}`}>
|
||
{money(v, cur, 2)}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OpeningEditor({ id, mkt, cur, opening, onSaved }: {
|
||
id: number; mkt: string; cur: string; opening: OpeningBalanceT | undefined; onSaved: () => void;
|
||
}) {
|
||
const [editing, setEditing] = useState(false);
|
||
const [amount, setAmount] = useState("");
|
||
const [reason, setReason] = useState("");
|
||
const [pickSource, setPickSource] = useState(false);
|
||
const [srcId, setSrcId] = useState<number | undefined>();
|
||
useEffect(() => {
|
||
setAmount(opening ? String(opening.amount) : "0");
|
||
setReason(opening?.reason ?? "");
|
||
}, [opening?.amount, opening?.reason]);
|
||
|
||
const { data: cands } = useQuery({
|
||
queryKey: ["opening-candidates", id], queryFn: () => api.openingCandidates(id),
|
||
});
|
||
const priors = cands?.candidates ?? [];
|
||
const chosen = srcId ?? priors[0]?.session_id;
|
||
|
||
const save = useMutation({
|
||
mutationFn: () => api.putOpenings(id, [{ marketplace: mkt, amount: Number(amount) || 0, reason, source: "manual" }]),
|
||
onSuccess: () => { setEditing(false); onSaved(); },
|
||
});
|
||
const carry = useMutation({
|
||
mutationFn: () => api.carryForwardOpenings(id, chosen),
|
||
onSuccess: () => { setPickSource(false); onSaved(); },
|
||
});
|
||
const reset = useMutation({ mutationFn: () => api.resetOpenings(id), onSuccess: onSaved });
|
||
|
||
return (
|
||
<div className="card p-4 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||
<div className="flex-1 min-w-[220px]">
|
||
<div className="text-xs font-semibold text-muted uppercase tracking-wide">
|
||
Opening AR balance · {mkt}
|
||
</div>
|
||
{!editing ? (
|
||
<div className="flex items-center gap-3 mt-1">
|
||
<span className="text-2xl font-semibold num text-ink">{money(opening?.amount ?? 0, cur)}</span>
|
||
<StatusBadge status={opening?.source === "carried_forward" ? "info" : "draft"} />
|
||
{opening?.reason && <span className="text-xs text-subink">{opening.reason}</span>}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-wrap items-center gap-2 mt-1.5">
|
||
<input className="input num w-48" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="0.00" />
|
||
<input className="input w-64" value={reason} onChange={(e) => setReason(e.target.value)}
|
||
placeholder="Reason for adjustment (required to change)" />
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-subink mt-1">
|
||
Entered manually for the first month; from month 2 it carries forward automatically from the prior closing.
|
||
</p>
|
||
</div>
|
||
{!editing ? (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button className="btn-ghost" onClick={() => setEditing(true)}><Pencil size={15} /> Adjust</button>
|
||
{priors.length > 0 && (
|
||
<button className="btn-ghost" disabled={carry.isPending}
|
||
onClick={() => setPickSource((v) => !v)}>
|
||
<CornerDownRight size={15} /> {carry.isPending ? "Carrying…" : "Carry forward"}
|
||
</button>
|
||
)}
|
||
<button className="btn-ghost" disabled={reset.isPending} onClick={() => reset.mutate()}>
|
||
<RotateCcw size={15} /> Set to zero
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex gap-2">
|
||
<button className="btn-ghost" onClick={() => setEditing(false)}>Cancel</button>
|
||
<button className="btn-primary" disabled={save.isPending} onClick={() => save.mutate()}>
|
||
<Save size={15} /> {save.isPending ? "Saving…" : "Save"}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{pickSource && priors.length > 0 && (
|
||
<div className="w-full border-t border-line pt-3 flex flex-wrap items-end gap-2">
|
||
<div className="flex-1 min-w-[220px]">
|
||
<label className="label mb-1">Carry forward from</label>
|
||
<select className="input py-1.5 text-sm" value={chosen ?? ""}
|
||
onChange={(e) => setSrcId(Number(e.target.value))}>
|
||
{priors.map((p) => (
|
||
<option key={p.session_id} value={p.session_id}>
|
||
{p.name}{p.reporting_month ? ` · ${p.reporting_month}` : ""}
|
||
{` — ${p.markets.length} marketplace${p.markets.length === 1 ? "" : "s"}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<button className="btn-ghost" onClick={() => setPickSource(false)}>Cancel</button>
|
||
<button className="btn-primary" disabled={carry.isPending} onClick={() => carry.mutate()}>
|
||
Apply to all marketplaces
|
||
</button>
|
||
<p className="w-full text-xs text-subink">
|
||
Sets every marketplace's opening balance to that closing's closing receivable.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{(carry.isError || reset.isError || save.isError) && (
|
||
<p className="w-full text-sm text-bad">
|
||
{((carry.error || reset.error || save.error) as Error)?.message}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|