mega-plant/components/SlipCapture.tsx
David 1ec52961ee Cash float card, Office role + Slips tab, job card PDF, manual close/delete
- Cash Floats: ledger card (opening balance, top-ups, slips), manual entries, slip photos
- Office access level; clock app Slips tab (photograph a slip, popup for supplier/cost
  code/comments/amount); only Office sees it; per-role route table in lib/clock-access
- Job cards: Machine Service PDF (with Oil block), JC numbers, manual Close job, Delete
- Hide Expenses > Vehicle Costs and Finance (Bastion) from the menu

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:09:57 +02:00

103 lines
7.4 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 }: { hasOpening: boolean; balance: number; costCodes: 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" value={supplier} onChange={e => setSupplier(e.target.value)} maxLength={200} autoComplete="off" placeholder="e.g. Builders Warehouse"/>
<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>;
}