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:
parent
1c48c74bd2
commit
89cfa5c442
3 changed files with 121 additions and 1 deletions
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue