import { useState, useEffect, useMemo } from 'react' import { useAuth } from '../App' interface LineItemAvail { id: number description: string | null unit: string | null quantity: number | null unit_price: number | null amount: number | null is_non_stock: boolean already_distributed_qty: number available_qty: number } interface InvoiceAvailability { invoice_id: number invoice_number: string | null invoice_date: string | null supplier_name: string | null line_items: LineItemAvail[] } interface EntryOut { id: number entry_date: string amount: number is_source_offset: boolean is_overpay: boolean } interface LineSelectionOut { id: number line_item_id: number description: string | null original_quantity: number | null selected_quantity: number unit_price: number distributed_value: number } interface DistributionDetail { id: number invoice_id: number invoice_number: string | null invoice_date: string | null supplier_name: string | null status: string method: string notes: string | null total_distributed_value: number remaining_balance: number source_date: string created_by_name: string | null created_at: string line_selections: LineSelectionOut[] entries: EntryOut[] } interface Selection { selected: boolean qty: number } interface Props { isOpen: boolean onClose: () => void onSaved: () => void invoiceId: number | null distributionId?: number | null isAdmin?: boolean } const DOW_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] export default function CostDistributionModal({ isOpen, onClose, onSaved, invoiceId, distributionId, isAdmin }: Props) { const { token } = useAuth() // Form state const [invoice, setInvoice] = useState(null) const [existing, setExisting] = useState(null) const [selections, setSelections] = useState>({}) const [method, setMethod] = useState<'OFFSET' | 'DISTRIBUTE'>('OFFSET') const [targetDate, setTargetDate] = useState('') const [daysOfWeek, setDaysOfWeek] = useState([true, true, true, true, true, false, false]) const [numWeeks, setNumWeeks] = useState(4) const [startDate, setStartDate] = useState('') const [notes, setNotes] = useState('') // UI state const [saving, setSaving] = useState(false) const [deleting, setDeleting] = useState(false) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) // Settle early state const [settleMode, setSettleMode] = useState(false) const [settleDate, setSettleDate] = useState('') const [settleAmount, setSettleAmount] = useState('') // Reset state when modal opens useEffect(() => { if (!isOpen) return setError(null) setSelections({}) setMethod('OFFSET') setTargetDate('') setDaysOfWeek([true, true, true, true, true, false, false]) setNumWeeks(4) setStartDate('') setNotes('') setExisting(null) setInvoice(null) setSettleMode(false) setSettleDate('') setSettleAmount('') }, [isOpen, invoiceId, distributionId]) // Load invoice availability or existing distribution useEffect(() => { if (!isOpen || !token) return if (distributionId) { // Load existing distribution setLoading(true) fetch(`/kitchen/api/cost-distributions/${distributionId}`, { credentials: 'include', }) .then(r => { if (!r.ok) throw new Error('Failed to load distribution'); return r.json() }) .then((data: DistributionDetail) => { setExisting(data) setNotes(data.notes || '') setLoading(false) }) .catch(e => { setError(e.message); setLoading(false) }) } else if (invoiceId) { // Load invoice availability setLoading(true) fetch(`/kitchen/api/cost-distributions/invoice/${invoiceId}/availability`, { credentials: 'include', }) .then(r => { if (!r.ok) throw new Error('Failed to load invoice data'); return r.json() }) .then((data: InvoiceAvailability) => { setInvoice(data) // Initialize selections const init: Record = {} for (const li of data.line_items) { if (!li.is_non_stock && li.available_qty > 0) { init[li.id] = { selected: false, qty: li.available_qty } } } setSelections(init) // Set default start date to next Monday after invoice date if (data.invoice_date) { const d = new Date(data.invoice_date) const day = d.getDay() // 0=Sun, 1=Mon... const daysUntilMon = day === 0 ? 1 : (8 - day) d.setDate(d.getDate() + daysUntilMon) setStartDate(d.toISOString().slice(0, 10)) } setLoading(false) }) .catch(e => { setError(e.message); setLoading(false) }) } }, [isOpen, token, invoiceId, distributionId]) // Calculate actual remaining balance from future entries (not the stale DB field) const actualRemaining = useMemo(() => { if (!existing) return 0 const today = new Date().toISOString().slice(0, 10) const total = existing.entries .filter(e => !e.is_source_offset && !e.is_overpay && e.entry_date > today) .reduce((sum, e) => sum + e.amount, 0) return Math.round(total * 100) / 100 }, [existing]) // Settable amount recalculates based on chosen settle date (entries from that date onwards) const settleMax = useMemo(() => { if (!existing || !settleDate) return actualRemaining const total = existing.entries .filter(e => !e.is_source_offset && !e.is_overpay && e.entry_date >= settleDate) .reduce((sum, e) => sum + e.amount, 0) return Math.round(total * 100) / 100 }, [existing, settleDate, actualRemaining]) // Auto-update settle amount when settle date changes useEffect(() => { if (settleMode && existing && settleDate) { const total = existing.entries .filter(e => !e.is_source_offset && !e.is_overpay && e.entry_date >= settleDate) .reduce((sum, e) => sum + e.amount, 0) setSettleAmount((Math.round(total * 100) / 100).toFixed(2)) } }, [settleDate, settleMode, existing]) // Calculate total distributed value from selections const totalDistValue = useMemo(() => { if (!invoice) return 0 let total = 0 for (const li of invoice.line_items) { const sel = selections[li.id] if (sel?.selected && li.unit_price) { total += sel.qty * li.unit_price } } return Math.round(total * 100) / 100 }, [invoice, selections]) // Generate preview dates for DISTRIBUTE method const previewEntries = useMemo(() => { if (method !== 'DISTRIBUTE' || !startDate || numWeeks < 1 || totalDistValue <= 0) return [] const selectedDows = daysOfWeek.reduce((acc, v, i) => { if (v) acc.push(i); return acc }, []) if (selectedDows.length === 0) return [] const dates: string[] = [] const start = new Date(startDate) for (let w = 0; w < numWeeks; w++) { for (let d = 0; d < 7; d++) { const current = new Date(start) current.setDate(start.getDate() + w * 7 + d) const dow = (current.getDay() + 6) % 7 // Convert JS Sun=0 to Mon=0 if (selectedDows.includes(dow)) { dates.push(current.toISOString().slice(0, 10)) } } } // Deduplicate and sort const unique = [...new Set(dates)].sort() if (unique.length === 0) return [] const perEntry = Math.round((totalDistValue / unique.length) * 100) / 100 const entries = unique.map((date, i) => ({ date, amount: i === unique.length - 1 ? Math.round((totalDistValue - perEntry * (unique.length - 1)) * 100) / 100 : perEntry, })) return entries }, [method, startDate, numWeeks, daysOfWeek, totalDistValue]) const handleSave = async () => { if (!token || !invoiceId) return setError(null) setSaving(true) try { const lineSelections = Object.entries(selections) .filter(([_, sel]) => sel.selected) .map(([id, sel]) => ({ line_item_id: parseInt(id), selected_quantity: sel.qty, })) if (lineSelections.length === 0) { setError('Please select at least one line item') setSaving(false) return } const body: any = { invoice_id: invoiceId, method, notes: notes || null, line_selections: lineSelections, } if (method === 'OFFSET') { if (!targetDate) { setError('Please select a target date'); setSaving(false); return } body.target_date = targetDate } else { const selectedDows = daysOfWeek.reduce((acc, v, i) => { if (v) acc.push(i); return acc }, []) if (selectedDows.length === 0) { setError('Please select at least one day'); setSaving(false); return } if (!startDate) { setError('Please select a start date'); setSaving(false); return } body.days_of_week = selectedDows body.num_weeks = numWeeks body.start_date = startDate } const res = await fetch('/kitchen/api/cost-distributions/', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) { const data = await res.json() throw new Error(data.detail || 'Failed to create distribution') } onSaved() onClose() } catch (e: any) { setError(e.message) } finally { setSaving(false) } } const handleUpdateNotes = async () => { if (!token || !distributionId) return setSaving(true) try { const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notes }), }) if (!res.ok) throw new Error('Failed to update') onSaved() onClose() } catch (e: any) { setError(e.message) } finally { setSaving(false) } } const handleDelete = async () => { if (!token || !distributionId) return if (!confirm('Are you sure you want to cancel this distribution? This will remove all scheduled entries.')) return setDeleting(true) try { const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}`, { method: 'DELETE', credentials: 'include', }) if (!res.ok) { const data = await res.json() throw new Error(data.detail || 'Failed to delete') } onSaved() onClose() } catch (e: any) { setError(e.message) } finally { setDeleting(false) } } const handleSettleEarly = async (settleAll?: boolean) => { if (!token || !distributionId || !settleDate) return setSaving(true) try { const body: any = { entry_date: settleDate } if (!settleAll && settleAmount) body.amount = parseFloat(settleAmount) const res = await fetch(`/kitchen/api/cost-distributions/${distributionId}/settle-early`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (!res.ok) { const data = await res.json() throw new Error(data.detail || 'Failed to settle') } onSaved() onClose() } catch (e: any) { setError(e.message) } finally { setSaving(false) } } if (!isOpen) return null const isViewing = !!distributionId && !!existing const isActive = existing?.status === 'ACTIVE' return (
e.stopPropagation()}> {/* Header */}

{isViewing ? `Cost Distribution #${existing.id}` : 'New Cost Distribution'}

{existing && ( {existing.status} )}
{/* Body */}
{loading &&
Loading...
} {error &&
{error}
} {/* Invoice Reference (read-only) */} {(invoice || existing) && (
)} {/* Notes */}