Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 45 additions & 6 deletions web/src/routes/DailyEntryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export function DailyEntryPage() {
// so an untouched re-save must not wipe an existing entry's grading.
const [gradesTouched, setGradesTouched] = useState(false);
const [existingStatus, setExistingStatus] = useState<string | null>(null);
// Prefill failure OR in-flight prefill blocks saving (silent-overwrite
// guard, #59); failedTarget marks which flock+date the failure was for.
const [prefillFailed, setPrefillFailed] = useState(false);
const [prefillPending, setPrefillPending] = useState(false);
const [prefillRetry, setPrefillRetry] = useState(0);
const failedTarget = useRef<string | null>(null);

const [busy, setBusy] = useState(false);
const inFlight = useRef(false);
Expand Down Expand Up @@ -78,10 +84,19 @@ export function DailyEntryPage() {
useEffect(() => {
if (!flockId || !date) return;
let cancelled = false;
// Saves stay blocked from the moment a prefill is in flight until it
// SUCCEEDS — clearing the flag optimistically would reopen the exact
// overwrite window this guard closes (#61 review).
setPrefillPending(true);
const target = `${flockId}|${date}`;
listDailyEntries({ flockId, from: date, to: date, limit: 1 })
.then((entries) => {
if (cancelled) return;
const existing = entries.find((e) => e.date === date);
// A retry that recovers for the SAME flock+date must not zero the form:
// the user may have typed while the banner was up, and with no server
// entry there is nothing to overwrite.
const isRetryRecovery = failedTarget.current === target;
if (existing) {
setTotalEggs(existing.totalEggs);
setCracked(existing.crackedEggs);
Expand All @@ -91,16 +106,28 @@ export function DailyEntryPage() {
setGradeQty(Object.fromEntries(existing.grades.map((g) => [g.eggGradeId, g.quantity])));
setGradesTouched(existing.grades.length > 0);
setExistingStatus(existing.status);
} else {
} else if (!isRetryRecovery) {
setTotalEggs(0); setCracked(0); setDirty(0); setDiscarded(0); setMortality(0);
setGradeQty({});
setGradesTouched(false);
setExistingStatus(null);
}
failedTarget.current = null;
setPrefillFailed(false);
setPrefillPending(false);
})
.catch(() => { /* prefill is best-effort; save still validates server-side */ });
.catch(() => {
// Not best-effort (#59): without the prefill we can't know whether this
// day already has data — saving would overwrite it with zeros. Block
// saving until a retry succeeds.
if (!cancelled) {
failedTarget.current = target;
setPrefillFailed(true);
setPrefillPending(false);
}
});
return () => { cancelled = true; };
}, [flockId, date]);
}, [flockId, date, prefillRetry]);

useEffect(() => {
if (flockId) localStorage.setItem(LAST_FLOCK_KEY, flockId);
Expand Down Expand Up @@ -143,7 +170,10 @@ export function DailyEntryPage() {
}

async function onSave(submit: boolean) {
if (inFlight.current || !selectedFlock) return; // sync re-entry guard
if (inFlight.current || !selectedFlock || prefillFailed || prefillPending) return; // sync re-entry guard
// One-way action (#59): submit freezes the day and creates egg lots.
if (submit && !window.confirm(
"Submit this day? Egg lots are created and the entry can no longer be edited — corrections need a manager adjustment.")) return;
inFlight.current = true;
setBusy(true);
setError(null);
Expand Down Expand Up @@ -252,6 +282,15 @@ export function DailyEntryPage() {
</p>
)}

{prefillFailed && (
<p className="error">
Could not check whether this day already has an entry — saving is blocked
so existing data isn't overwritten.{" "}
<button className="link" type="button"
onClick={() => setPrefillRetry((n) => n + 1)}>retry</button>
</p>
)}

<h3>Sellable production by grade</h3>
<div className="form-grid">
{grades.map((g) => (
Expand All @@ -274,10 +313,10 @@ export function DailyEntryPage() {
{message && <p className="success">{message}</p>}

<div className="actions">
<button disabled={busy || !flockId || lossesExceedTotal || entryLocked}
<button disabled={busy || !flockId || lossesExceedTotal || entryLocked || prefillFailed || prefillPending}
onClick={() => onSave(false)}>Save draft</button>
<button
disabled={busy || !flockId || lossesExceedTotal || gradesSum > sellable || entryLocked}
disabled={busy || !flockId || lossesExceedTotal || gradesSum > sellable || entryLocked || prefillFailed || prefillPending}
onClick={() => onSave(true)}>
Save &amp; submit (creates egg lots)
</button>
Expand Down
11 changes: 9 additions & 2 deletions web/src/routes/FlocksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,13 +268,20 @@ export function FlocksPage() {
onClick={() => startEdit(f)}>edit</button>
{f.status === "Active" && (
<button className="link" disabled={busy}
onClick={() => void run(`deplete:${f.id}`, (key) => depleteFlock(f.id, key))}>
onClick={() => {
// One-way until reactivate ships (#57/#59).
if (window.confirm(`Deplete "${f.name}"? The flock stops accepting new entries (backfill for past dates still works).`))
void run(`deplete:${f.id}`, (key) => depleteFlock(f.id, key));
}}>
deplete
</button>
)}
{f.status !== "Archived" && (
<button className="link" disabled={busy}
onClick={() => void run(`archive:${f.id}`, (key) => archiveFlock(f.id, key))}>
onClick={() => {
if (window.confirm(`Archive "${f.name}"? It disappears from pickers and the dashboard and accepts nothing new.`))
void run(`archive:${f.id}`, (key) => archiveFlock(f.id, key));
}}>
archive
</button>
)}
Expand Down
51 changes: 32 additions & 19 deletions web/src/routes/SalesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,26 +154,39 @@ export function SalesPage() {
clearKey(scope);
});

const onConfirm = () => run(async () => {
if (!active) return;
const scope = `confirm:${active.id}`;
await confirmOrder(active.id, keyFor(scope));
const refreshed = await getOrder(active.id);
setActive(refreshed);
setMessage(`Order ${refreshed.referenceNumber} confirmed — stock allocated (FIFO).`);
await loadOrders();
clearKey(scope);
});
// One-way actions (#59). Confirm BEFORE run() so buttons don't flash
// disabled while the user decides.
const onConfirm = () => {
// Allocation cannot be undone yet (void is #60).
if (!window.confirm(
"Confirm this order? Stock is allocated from inventory (FIFO) and cannot be undone.")) return;
void run(async () => {
if (!active) return;
const scope = `confirm:${active.id}`;
await confirmOrder(active.id, keyFor(scope));
const refreshed = await getOrder(active.id);
setActive(refreshed);
setMessage(`Order ${refreshed.referenceNumber} confirmed — stock allocated (FIFO).`);
await loadOrders();
clearKey(scope);
});
};

const onCancel = () => run(async () => {
if (!active) return;
const scope = `cancel:${active.id}`;
await cancelOrder(active.id, keyFor(scope));
setActive(null);
setMessage("Draft order cancelled.");
await loadOrders();
clearKey(scope);
});
const onCancel = () => {
// Cancel is a status change: the order keeps its lines but becomes
// read-only and can't be confirmed.
if (!window.confirm(
"Cancel this draft? The order becomes cancelled and can no longer be edited or confirmed.")) return;
void run(async () => {
if (!active) return;
const scope = `cancel:${active.id}`;
await cancelOrder(active.id, keyFor(scope));
setActive(null);
setMessage("Draft order cancelled.");
await loadOrders();
clearKey(scope);
});
};

// Always fetch fresh on open — the list row may be stale relative to
// mutations made through the panel since the list was loaded.
Expand Down
Loading