- /expenses/suppliers: terms (cash / 30 / 60 days), PO + petty cash totals per supplier, period filters, add/edit/delete, due dates - POs and slips resolve to a supplier by code then folded name; new PO suppliers start with terms unset, new slip suppliers as cash - Full-user guard on cash-float and job-card write routes Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
104 lines
7.6 KiB
TypeScript
104 lines
7.6 KiB
TypeScript
'use client';
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { AlertTriangle, Camera, CheckCircle2, X } from 'lucide-react';
|
|
import { shrinkImage } from '@/lib/shrink-image';
|
|
import { rand } from '@/lib/cash-float';
|
|
|
|
/**
|
|
* Photograph a cash-card slip, fill in what it was for, and it comes off the card's balance.
|
|
* The first time ever, the card's opening balance is asked for.
|
|
*/
|
|
export function SlipCapture({ hasOpening, balance, costCodes, suppliers }: { hasOpening: boolean; balance: number; costCodes: string[]; suppliers: string[] }) {
|
|
const router = useRouter();
|
|
const [photo, setPhoto] = useState<File | null>(null);
|
|
const [preview, setPreview] = useState('');
|
|
const [supplier, setSupplier] = useState('');
|
|
const [costCode, setCostCode] = useState('');
|
|
const [comment, setComment] = useState('');
|
|
const [amount, setAmount] = useState('');
|
|
const [opening, setOpening] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [message, setMessage] = useState('');
|
|
|
|
async function picked(file: File | undefined) {
|
|
if (!file) return;
|
|
const small = await shrinkImage(file, 'slip.jpg', 1800);
|
|
setPhoto(small); setPreview(URL.createObjectURL(small));
|
|
setSupplier(''); setCostCode(''); setComment(''); setAmount(''); setError(''); setMessage('');
|
|
}
|
|
function close() { if (!busy) { setPhoto(null); setPreview(''); } }
|
|
|
|
async function saveOpening(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setBusy(true); setError('');
|
|
try {
|
|
const res = await fetch('/api/clock/cash-float/opening', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: opening }) });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || 'Could not save.');
|
|
router.refresh();
|
|
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save.'); } finally { setBusy(false); }
|
|
}
|
|
async function saveSlip(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!photo) return;
|
|
setBusy(true); setError('');
|
|
try {
|
|
const body = new FormData();
|
|
body.set('slip_photo', photo); body.set('supplier', supplier); body.set('cost_code', costCode); body.set('comment', comment); body.set('amount', amount);
|
|
const res = await fetch('/api/clock/slip', { method: 'POST', body });
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || 'Could not save.');
|
|
setMessage(`Slip for ${rand(data.amount)} saved. Card balance: ${rand(data.balance)}.`);
|
|
setPhoto(null); setPreview(''); router.refresh(); navigator.vibrate?.(120);
|
|
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save.'); } finally { setBusy(false); }
|
|
}
|
|
|
|
return <section className="plant-clock-page p-4 sm:p-6 max-w-3xl mx-auto">
|
|
<p className="plant-clock-eyebrow">MEGA PLANT</p>
|
|
<h1 className="text-2xl font-semibold tracking-tight mt-1">Slips</h1>
|
|
|
|
{message && <div role="status" className="plant-clock-banner mt-4 text-white bg-[#00798C]"><CheckCircle2 size={26} className="shrink-0"/><p className="text-base font-semibold break-words">{message}</p></div>}
|
|
|
|
{!hasOpening ? <form onSubmit={saveOpening} className="plant-clock-panel mt-4 p-4 sm:p-5">
|
|
<h2 className="text-lg font-semibold">First time: set up the card</h2>
|
|
<p className="text-sm text-text-muted mt-1">What is the balance on the card right now? Every slip you photograph will come off this amount.</p>
|
|
<label htmlFor="opening" className="block text-sm font-medium mt-4">Opening balance (R)</label>
|
|
<input id="opening" className="plant-clock-input mt-1 text-2xl tabular-nums" type="text" inputMode="decimal" autoFocus value={opening} onChange={e => setOpening(e.target.value)} placeholder="0.00"/>
|
|
{error && <p role="alert" className="plant-clock-warning mt-3">{error}</p>}
|
|
<button className="plant-clock-button plant-clock-primary w-full mt-4" type="submit" disabled={busy || !opening.trim()}>{busy ? 'Saving…' : 'Save opening balance'}</button>
|
|
</form> : <>
|
|
<div className="plant-clock-panel mt-4 p-4 sm:p-5">
|
|
<p className="text-sm text-text-muted">Card balance</p>
|
|
<p className={`text-4xl font-semibold tabular-nums mt-1 ${balance < 0 ? 'text-[#D1495B]' : ''}`}>{rand(balance)}</p>
|
|
</div>
|
|
<label htmlFor="slip-photo" className="plant-clock-button plant-clock-primary w-full mt-4 min-h-[64px] text-lg cursor-pointer"><Camera size={24}/>Take photo of slip</label>
|
|
<input id="slip-photo" type="file" accept="image/*" capture="environment" className="sr-only" onChange={e => { void picked(e.target.files?.[0]); e.target.value = ''; }}/>
|
|
<p className="text-sm text-text-muted mt-2">Take a clear photo of the slip. You will then fill in the supplier, cost code, comments and amount.</p>
|
|
</>}
|
|
|
|
{photo && <div className="fixed inset-0 z-50 grid place-items-end sm:place-items-center bg-black/60 p-0 sm:p-4" onClick={close}>
|
|
<form onClick={e => e.stopPropagation()} onSubmit={saveSlip} role="dialog" aria-modal="true" aria-label="Slip details"
|
|
className="w-full max-w-md max-h-[94svh] overflow-y-auto rounded-t-2xl sm:rounded-2xl bg-surface p-4 sm:p-5 shadow-xl">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<h2 className="text-lg font-semibold">Slip details</h2>
|
|
<button type="button" onClick={close} disabled={busy} className="plant-clock-button plant-clock-secondary shrink-0" aria-label="Cancel"><X size={16}/></button>
|
|
</div>
|
|
<img src={preview} alt="Photo of the slip" className="mt-3 max-h-48 w-full rounded-xl object-contain bg-bg border border-border"/>
|
|
<label htmlFor="supplier" className="block text-sm font-medium mt-4">Supplier</label>
|
|
<input id="supplier" className="plant-clock-input mt-1" list="slip-suppliers" value={supplier} onChange={e => setSupplier(e.target.value)} maxLength={200} autoComplete="off" placeholder="e.g. Builders Warehouse"/>
|
|
<datalist id="slip-suppliers">{suppliers.map(s => <option key={s} value={s}/>)}</datalist>
|
|
<label htmlFor="cost-code" className="block text-sm font-medium mt-3">Cost code <span className="font-normal text-text-muted">(optional)</span></label>
|
|
<input id="cost-code" className="plant-clock-input mt-1" list="slip-cost-codes" value={costCode} onChange={e => setCostCode(e.target.value)} maxLength={100} autoComplete="off" autoCapitalize="characters" placeholder="e.g. TLB04"/>
|
|
<datalist id="slip-cost-codes">{costCodes.map(c => <option key={c} value={c}/>)}</datalist>
|
|
<label htmlFor="amount" className="block text-sm font-medium mt-3">Amount on the slip (R)</label>
|
|
<input id="amount" className="plant-clock-input mt-1 text-2xl tabular-nums" type="text" inputMode="decimal" value={amount} onChange={e => setAmount(e.target.value)} placeholder="0.00"/>
|
|
<label htmlFor="comment" className="block text-sm font-medium mt-3">Comments <span className="font-normal text-text-muted">(optional)</span></label>
|
|
<textarea id="comment" className="plant-clock-input mt-1 min-h-[80px]" value={comment} onChange={e => setComment(e.target.value)} maxLength={1000} placeholder="What was it for?"/>
|
|
{error && <p role="alert" className="plant-clock-warning mt-3 flex items-start gap-2"><AlertTriangle size={18} className="shrink-0 mt-0.5"/>{error}</p>}
|
|
<button className="plant-clock-button plant-clock-primary w-full mt-4" type="submit" disabled={busy || !supplier.trim() || !amount.trim()}>{busy ? 'Saving…' : 'Save slip'}</button>
|
|
</form>
|
|
</div>}
|
|
</section>;
|
|
}
|