/// import { type Cell, cell, Default, handler, lift, recipe, str, } from "commontools"; type ClaimStatus = "submitted" | "approved" | "rejected" | "paid"; type ActionKind = "approved" | "rejected" | "paid"; interface ExpenseClaimInput { id?: string; employee?: string; description?: string; amount?: number; status?: string; } interface ExpenseClaim { id: string; employee: string; description: string; amount: number; status: ClaimStatus; } interface ExpenseTotals { submitted: number; approved: number; rejected: number; paid: number; pendingPayment: number; totalRequested: number; } interface ExpenseReimbursementArgs { claims: Default; } interface StatusChangeEvent { id?: string; } interface StatusHandlerContext { claims: Cell; history: Cell; latestAction: Cell; sequence: Cell; } const defaultClaims: ExpenseClaimInput[] = [ { id: "travel-001", employee: "Avery", description: "Quarterly offsite travel", amount: 186.25, status: "submitted", }, { id: "supplies-002", employee: "Briar", description: "Workshop materials", amount: 92.4, status: "submitted", }, { id: "team-lunch-003", employee: "Sky", description: "Team lunch with client", amount: 48.5, status: "approved", }, { id: "lodging-004", employee: "Riley", description: "Conference lodging", amount: 220, status: "paid", }, ]; const roundCurrency = (value: number): number => { return Math.round(value * 100) / 100; }; const sanitizeAmount = (value: unknown, fallback: number): number => { if (typeof value !== "number" || !Number.isFinite(value)) { return roundCurrency(Math.max(fallback, 0)); } return roundCurrency(Math.max(0, value)); }; const normalizeText = (value: unknown, fallback: string): string => { if (typeof value === "string") { const trimmed = value.trim(); if (trimmed.length > 0) return trimmed; } return fallback; }; const normalizeStatus = ( value: unknown, fallback: ClaimStatus, ): ClaimStatus => { if (typeof value !== "string") return fallback; const normalized = value.toLowerCase(); if ( normalized === "submitted" || normalized === "approved" || normalized === "rejected" || normalized === "paid" ) { return normalized; } return fallback; }; const slugify = (value: string): string => { return value.toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); }; const slugOrFallback = (value: unknown, fallback: string): string => { if (typeof value !== "string") return fallback; const trimmed = value.trim(); if (trimmed.length === 0) return fallback; const slug = slugify(trimmed); return slug.length > 0 ? slug : fallback; }; const ensureUniqueId = ( base: string, used: Set, fallback: string, ): string => { const initial = base.length > 0 ? base : fallback; let candidate = initial; let suffix = 2; while (used.has(candidate)) { candidate = `${initial}-${suffix}`; suffix++; } used.add(candidate); return candidate; }; const sanitizeClaimList = ( value: readonly ExpenseClaimInput[] | undefined, ): ExpenseClaim[] => { const source = Array.isArray(value) ? value : []; const base = source.length > 0 ? source : defaultClaims; const usedIds = new Set(); const sanitized: ExpenseClaim[] = []; for (let index = 0; index < base.length; index++) { const raw = base[index]; const defaults = defaultClaims[index] ?? {}; const fallbackEmployee = normalizeText( defaults.employee, `Employee ${index + 1}`, ); const fallbackDescription = normalizeText( defaults.description, `Expense ${index + 1}`, ); const fallbackAmount = sanitizeAmount(defaults.amount, 0); const fallbackStatus = normalizeStatus(defaults.status, "submitted"); const fallbackSlug = slugOrFallback(defaults.id, `claim-${index + 1}`); const baseSlug = slugOrFallback(raw?.id, fallbackSlug); const id = ensureUniqueId(baseSlug, usedIds, fallbackSlug); const employee = normalizeText(raw?.employee, fallbackEmployee); const description = normalizeText(raw?.description, fallbackDescription); const amount = sanitizeAmount(raw?.amount, fallbackAmount); const status = normalizeStatus(raw?.status, fallbackStatus); sanitized.push({ id, employee, description, amount, status }); } if (sanitized.length === 0) { return sanitizeClaimList(defaultClaims); } return sanitized; }; const calculateTotals = (claims: readonly ExpenseClaim[]): ExpenseTotals => { let submitted = 0; let approved = 0; let rejected = 0; let paid = 0; for (const claim of claims) { const amount = roundCurrency(claim.amount); switch (claim.status) { case "submitted": submitted += amount; break; case "approved": approved += amount; break; case "rejected": rejected += amount; break; case "paid": paid += amount; break; } } const totalRequested = claims.reduce( (sum, claim) => sum + roundCurrency(claim.amount), 0, ); return { submitted: roundCurrency(submitted), approved: roundCurrency(approved), rejected: roundCurrency(rejected), paid: roundCurrency(paid), pendingPayment: roundCurrency(approved), totalRequested: roundCurrency(totalRequested), }; }; const formatCurrency = (value: number): string => { return `$${roundCurrency(value).toFixed(2)}`; }; const buildSummaryLabel = (totals: ExpenseTotals): string => { const requested = formatCurrency(totals.totalRequested); const paid = formatCurrency(totals.paid); const pending = formatCurrency(totals.pendingPayment); return `Recorded ${requested} in claims; reimbursed ${paid}; pending ${pending}.`; }; const buildActionMessage = ( kind: ActionKind, claim: ExpenseClaim, ): string => { const amount = formatCurrency(claim.amount); if (kind === "approved") { return `Approved ${claim.id} for ${claim.employee} (${amount})`; } if (kind === "rejected") { return `Rejected ${claim.id} for ${claim.employee} (${amount})`; } return `Recorded payment for ${claim.id} (${amount})`; }; const buildStatusChangeHandler = ( kind: ActionKind, nextStatus: ClaimStatus, allowed: readonly ClaimStatus[], ) => handler( (event: StatusChangeEvent | undefined, context: StatusHandlerContext) => { const id = typeof event?.id === "string" ? event.id.trim() : ""; if (id.length === 0) return; const sanitized = sanitizeClaimList(context.claims.get()); const index = sanitized.findIndex((claim) => claim.id === id); if (index === -1) return; const target = sanitized[index]; if (!allowed.includes(target.status)) return; const updatedClaim: ExpenseClaim = { ...target, status: nextStatus }; const nextClaims = sanitized.map((claim, claimIndex) => claimIndex === index ? updatedClaim : claim ); context.claims.set(nextClaims.map((claim) => ({ ...claim }))); const message = buildActionMessage(kind, updatedClaim); context.latestAction.set(message); const previousHistory = context.history.get() ?? []; const appended = [...previousHistory, message]; const trimmed = appended.length > 5 ? appended.slice(-5) : appended; context.history.set(trimmed); const sequence = (context.sequence.get() ?? 0) + 1; context.sequence.set(sequence); }, ); export const expenseReimbursement = recipe( "Expense Reimbursement Tracker", ({ claims }) => { const history = cell(["Reimbursement tracker initialized"]); const latestAction = cell("Reimbursement tracker initialized"); const sequence = cell(0); const claimList = lift(sanitizeClaimList)(claims); const totals = lift(calculateTotals)(claimList); const claimCount = lift((entries: readonly ExpenseClaim[]) => entries.length )( claimList, ); const submittedTotal = lift((data: ExpenseTotals) => data.submitted)( totals, ); const approvedTotal = lift((data: ExpenseTotals) => data.approved)(totals); const rejectedTotal = lift((data: ExpenseTotals) => data.rejected)(totals); const paidTotal = lift((data: ExpenseTotals) => data.paid)(totals); const pendingPayment = lift((data: ExpenseTotals) => data.pendingPayment)( totals, ); const summaryLabel = lift(buildSummaryLabel)(totals); const statusHeadline = str`${claimCount} claims ready for review`; const handlerContext = { claims, history, latestAction, sequence }; return { claims, claimList, totals, claimCount, submittedTotal, approvedTotal, rejectedTotal, paidTotal, pendingPayment, summaryLabel, statusHeadline, latestAction, activityLog: history, approveClaim: buildStatusChangeHandler( "approved", "approved", ["submitted"], )(handlerContext), recordPayment: buildStatusChangeHandler( "paid", "paid", ["approved"], )(handlerContext), rejectClaim: buildStatusChangeHandler( "rejected", "rejected", ["submitted", "approved"], )(handlerContext), }; }, );