feat: supplier-reason note on invoice mismatches; confirm before Collect all

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-22 04:14:47 +02:00
parent 1c48c74bd2
commit 89cfa5c442
3 changed files with 121 additions and 1 deletions

View file

@ -7,9 +7,11 @@ import {
RUN_EVENT,
collectMatched,
matchInvoice,
saveReviewNote,
undoMatched,
type InvoiceMatchResult,
type MatchedLine,
type ReviewLine,
} from '@/lib/invoiceCheck';
interface InvoiceMatchHostProps {
@ -31,6 +33,11 @@ export default function InvoiceMatchHost({ onCollected, onRestored }: InvoiceMat
const [collected, setCollected] = useState<MatchedLine[]>([]);
const [failedIds, setFailedIds] = useState<string[]>([]);
const [error, setError] = useState('');
const [noteFor, setNoteFor] = useState<string | null>(null);
const [noteText, setNoteText] = useState('');
const [noteBusy, setNoteBusy] = useState(false);
const [noteError, setNoteError] = useState('');
const [savedNotes, setSavedNotes] = useState<string[]>([]);
const running = useRef(false);
const run = useCallback(
@ -44,6 +51,8 @@ export default function InvoiceMatchHost({ onCollected, onRestored }: InvoiceMat
setResult(null);
setCollected([]);
setFailedIds([]);
setNoteFor(null);
setSavedNotes([]);
try {
const r = await matchInvoice(po);
setResult(r);
@ -94,6 +103,26 @@ export default function InvoiceMatchHost({ onCollected, onRestored }: InvoiceMat
onRestored();
};
const saveNote = async (line: ReviewLine) => {
if (!noteText.trim()) {
setNoteError('Write the reason first.');
return;
}
setNoteBusy(true);
setNoteError('');
try {
await saveReviewNote(line, noteText);
setSavedNotes((prev) => [...prev, line.id]);
setNoteFor(null);
setNoteText('');
} catch (e) {
console.warn('[invoice-match] note failed for', line.id, e);
setNoteError('Could not save the note. Try again.');
} finally {
setNoteBusy(false);
}
};
if (!open) return null;
return (
@ -153,6 +182,50 @@ export default function InvoiceMatchHost({ onCollected, onRestored }: InvoiceMat
<div className="min-w-0">
<p className="text-[14px] text-[#26251e] leading-snug break-words">{l.description}</p>
<p className="text-[12px] text-[rgba(38,37,30,0.7)] break-words">{l.reason}</p>
{savedNotes.includes(l.id) ? (
<p className="text-[12px] font-semibold text-[#125a3e] mt-1">Note saved</p>
) : noteFor === l.id ? (
<div className="mt-2">
<label htmlFor={`note-${l.id}`} className="block text-[12px] font-semibold text-[#26251e] mb-1">
Ask the supplier why it differs, then write their answer. We can’t be invoiced for what we haven’t received.
</label>
<textarea
id={`note-${l.id}`}
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={3}
className="w-full rounded-lg border border-[rgba(38,37,30,0.2)] bg-white p-2 text-[14px] text-[#26251e]"
placeholder="Reason from the supplier…"
/>
{noteError && <p className="text-[12px] text-[#cf2d56] mt-1">{noteError}</p>}
<div className="flex gap-2 mt-2">
<button
type="button"
disabled={noteBusy}
onClick={() => void saveNote(l)}
className="h-10 px-4 rounded-lg text-[13px] font-semibold bg-[#26251e] text-white disabled:opacity-60 touch-manipulation"
>
{noteBusy ? 'Saving…' : 'Save note'}
</button>
<button
type="button"
disabled={noteBusy}
onClick={() => { setNoteFor(null); setNoteText(''); setNoteError(''); }}
className="h-10 px-4 rounded-lg text-[13px] font-semibold bg-[#efeaf7] text-[#6c5a99] touch-manipulation"
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => { setNoteFor(l.id); setNoteText(''); setNoteError(''); }}
className="mt-1.5 h-9 px-3 rounded-lg text-[12px] font-semibold bg-[#efeaf7] text-[#6c5a99] touch-manipulation"
>
Add a note
</button>
)}
</div>
</li>
))}

View file

@ -296,6 +296,8 @@ export default function SupplierGroup({
// activeTooltip above, this drives whether the section lifts to z-30
// to paint above the next supplier/area group.
const [childPopoverOpen, setChildPopoverOpen] = useState(false);
// Collect all is one tap that clears a whole PO, so it asks first.
const [confirmCollect, setConfirmCollect] = useState<{ poLabel: string; items: OrderItem[] } | null>(null);
const listRef = useRef<HTMLUListElement>(null);
// Row sheets close themselves (backdrop tap, Escape, the X button),
// so there is no click-outside listener any more — it would have
@ -455,7 +457,7 @@ export default function SupplierGroup({
{showBulk && (
<>
<button
onClick={() => onBulkCollect?.(poLabel, po.items)}
onClick={() => setConfirmCollect({ poLabel, items: po.items })}
aria-label={`Mark all ${po.items.length} items on ${poLabel} collected`}
className="h-9 px-3 rounded-lg bg-[#1f8a65] text-white text-[13px] font-semibold flex items-center gap-1.5 active:bg-[#125a3e] transition-colors touch-manipulation"
>
@ -553,6 +555,38 @@ export default function SupplierGroup({
)}
</div>
)}
{confirmCollect && (
<BottomSheet
title="Collect everything?"
subtitle={confirmCollect.poLabel}
onClose={() => setConfirmCollect(null)}
>
<p className="text-[15px] text-[#26251e] mb-4">
This marks all {confirmCollect.items.length} item{confirmCollect.items.length === 1 ? '' : 's'} on {confirmCollect.poLabel} as collected and removes them from your list.
Photograph the invoice first if you haven’t yet.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setConfirmCollect(null)}
className="h-12 flex-1 rounded-xl text-[15px] font-semibold bg-[#efeaf7] text-[#6c5a99] touch-manipulation"
>
No, go back
</button>
<button
type="button"
onClick={() => {
const c = confirmCollect;
setConfirmCollect(null);
onBulkCollect?.(c.poLabel, c.items);
}}
className="h-12 flex-1 rounded-xl text-[15px] font-semibold bg-[#1f8a65] text-white touch-manipulation"
>
Yes, collect all
</button>
</div>
</BottomSheet>
)}
</section>
);
}

View file

@ -77,3 +77,16 @@ export async function undoMatched(line: MatchedLine): Promise<void> {
.collection(line.kind === 'stock' ? 'stock_purchase_items' : 'order_items')
.update(line.id, line.previous);
}
/**
* Record why a line differs from the invoice. Only the note is written: the
* item is not collected and nothing else about it changes. Any earlier note
* on the line is kept.
*/
export async function saveReviewNote(line: ReviewLine, reason: string): Promise<void> {
const col = line.kind === 'stock' ? 'stock_purchase_items' : 'order_items';
const rec = await pb.collection(col).getOne(line.id);
const before = String(rec.notes ?? '').trim();
const entry = `[Invoice differs: ${line.reason}] ${reason.trim()}`;
await pb.collection(col).update(line.id, { notes: before ? `${before}\n${entry}` : entry });
}