Skip to content

Commit f74956a

Browse files
gpascucciclaude
andcommitted
feat(29.6): add useScheduleMutations; route Schedule 1 through guarded run()
Story 1.6 (tech hardening). New useScheduleMutations({path, millId, year, isCurrent}) composes useScheduleBanners and exposes save/remove/checkStatus that dispatch through run() — so pages get the isCurrent() stale-response guard (no more stale repaint on a mid-flight mill/year switch) and stop re-hand-rolling the request/error/lock scaffolding. Schedule 1 converted as the exemplar: save/delete/check-status now go through the hook. The old separate `checking` lock folds into the single `saving` lock (both write actions gate together while any write is in flight). Delete keeps its in-place empty-state at the call site (a re-GET would 404 for a single-doc schedule) per the agreed convergence. Behavior preserved: 85/85 Schedule 1 tests pass, tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a9de69 commit f74956a

2 files changed

Lines changed: 129 additions & 71 deletions

File tree

frontend/src/components/schedule1/index.tsx

Lines changed: 51 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type Schedule1Response from '@/interfaces/Schedule1Response'
33
import type { LineItem } from '@/interfaces/Schedule1Response'
44
import type Schedule1Request from '@/interfaces/Schedule1Request'
55
import type CheckStatusResponse from '@/interfaces/CheckStatusResponse'
6-
import { useCallback, useState } from 'react'
6+
import { useState } from 'react'
77
import { useNavigate } from '@tanstack/react-router'
88
import {
99
Button,
@@ -21,11 +21,10 @@ import {
2121
TextArea,
2222
TextInput,
2323
} from '@carbon/react'
24-
import apiService from '@/service/api-service'
2524
import { WRITABLE_LINE_ITEM_CODES } from '@/interfaces/Schedule1Request'
26-
import useMillYear from '@/context/millYear/useMillYear'
25+
import { useScheduleContextGuard } from '@/hooks/useScheduleContextGuard'
2726
import { useScheduleDocument } from '@/hooks/useScheduleDocument'
28-
import { extractDetail } from '@/utils/error'
27+
import { useScheduleMutations } from '@/hooks/useScheduleMutations'
2928
import { fmtCurrency, fmtNumber, groupInput, numStrGroup, toNum } from '@/utils/number'
3029
import LoadingScreen from '@/components/core/LoadingScreen'
3130
import NotificationColumn from '@/components/core/NotificationColumn'
@@ -123,25 +122,30 @@ function buildRequest(doc: Schedule1Response, form: FieldValues): Schedule1Reque
123122
}
124123

125124
const Schedule1: FC = () => {
126-
const { millId, year } = useMillYear()
125+
const { millId, year, contextMissing, isCurrent } = useScheduleContextGuard()
127126
const navigate = useNavigate()
128-
const contextMissing = millId === null || year === null
129127

130-
const [saving, setSaving] = useState(false)
131-
const [saveMessage, setSaveMessage] = useState<string | null>(null)
132-
const [saveError, setSaveError] = useState<string | null>(null)
128+
// Save/delete/check-status all run through the shared hook's guarded run() (Story 29.6): a stale
129+
// in-flight write can no longer repaint a newly-switched mill/year. `saving` is the single in-flight
130+
// lock for every write (it also gates Check Status), replacing the old separate save/checking locks.
131+
const {
132+
saving,
133+
message: saveMessage,
134+
actionError: saveError,
135+
checkResult,
136+
setMessage: setSaveMessage,
137+
setActionError: setSaveError,
138+
setCheckResult,
139+
clearBanners,
140+
resetBanners,
141+
save,
142+
remove,
143+
checkStatus,
144+
} = useScheduleMutations<CheckStatusResponse>({ path: '/v1/schedule1', millId, year, isCurrent })
145+
133146
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
134147
const [confirmNavOpen, setConfirmNavOpen] = useState(false)
135148
const [otherCostsBlockedOpen, setOtherCostsBlockedOpen] = useState(false)
136-
const [checking, setChecking] = useState(false)
137-
const [checkResult, setCheckResult] = useState<CheckStatusResponse | null>(null)
138-
139-
// Clear the save/check notifications whenever a fresh document loads (mill/year change).
140-
const resetMessages = useCallback(() => {
141-
setSaveMessage(null)
142-
setSaveError(null)
143-
setCheckResult(null)
144-
}, [])
145149

146150
const { data, setData, form, setForm, setField, errorDetail, isLoading } =
147151
useScheduleDocument<Schedule1Response>({
@@ -151,7 +155,7 @@ const Schedule1: FC = () => {
151155
contextMissing,
152156
seedForm,
153157
mapLoadError: mapLoadErrorDetail,
154-
onReset: resetMessages,
158+
onReset: resetBanners,
155159
})
156160

157161
// Re-group a numeric field's value on blur, so it reads like the plain-text cells beside it. Only
@@ -177,44 +181,30 @@ const Schedule1: FC = () => {
177181
setSaveError('Please correct the highlighted fields before saving.')
178182
return
179183
}
180-
setSaving(true)
181-
setSaveMessage(null)
182-
setSaveError(null)
183-
setCheckResult(null) // a prior Check Status result is stale once the data changes
184-
apiService
185-
.getAxiosInstance()
186-
.put<Schedule1Response>(
187-
`/v1/schedule1?millId=${millId}&year=${year}`,
188-
buildRequest(data, form),
189-
)
190-
.then((response) => {
191-
setData(response.data)
192-
setForm(seedForm(response.data))
184+
clearBanners() // drop any prior banners incl. a now-stale Check Status result
185+
save<Schedule1Response>(buildRequest(data, form), {
186+
fallback: 'Schedule could not be saved.',
187+
onSuccess: (doc) => {
188+
setData(doc)
189+
setForm(seedForm(doc))
193190
// SUC-001 verbatim from the API message field (AD-8), never hardcoded.
194-
setSaveMessage(response.data.message?.text ?? null)
195-
})
196-
.catch((error: unknown) => {
197-
// Keep the entered values (S23/S24); surface the API's verbatim ProblemDetail.detail.
198-
setSaveError(extractDetail(error) || 'Schedule could not be saved.')
199-
})
200-
.finally(() => setSaving(false))
191+
setSaveMessage(doc.message?.text ?? null)
192+
},
193+
})
201194
}
202195

203196
const handleDelete = () => {
204197
if (saving) {
205198
return
206199
}
207200
setConfirmDeleteOpen(false)
208-
setSaving(true)
209-
setSaveMessage(null)
210-
setSaveError(null)
211-
setCheckResult(null) // the deleted schedule's check result is stale
212-
apiService
213-
.getAxiosInstance()
214-
.delete<{ message?: { text?: string } }>(`/v1/schedule1?millId=${millId}&year=${year}`)
215-
.then((response) => {
216-
// Delete removed the summary; a re-GET would 404, so reset to an empty schedule in place
217-
// (no re-fetch) and show SUC-002 from the API message.
201+
clearBanners() // the deleted schedule's check result / save banner are stale
202+
remove<{ message?: { text?: string } }>({
203+
fallback: 'Unable to delete Schedule 1.',
204+
// Delete removed the summary; a re-GET would 404, so reset to an empty schedule in place (no
205+
// re-fetch) and show SUC-002 from the API message. This per-page empty-state lives at the call
206+
// site (Story 29.6): single-doc Schedules 1/3 reset in place; list pages re-seed from a reload.
207+
onSuccess: (resp) => {
218208
setData((prev) =>
219209
prev
220210
? {
@@ -236,32 +226,20 @@ const Schedule1: FC = () => {
236226
: prev,
237227
)
238228
setForm({})
239-
setSaveMessage(response.data?.message?.text ?? null)
240-
})
241-
.catch((error: unknown) => {
242-
setSaveError(extractDetail(error) || 'Unable to delete Schedule 1.')
243-
})
244-
.finally(() => setSaving(false))
229+
setSaveMessage(resp?.message?.text ?? null)
230+
},
231+
})
245232
}
246233

247234
const handleCheckStatus = () => {
248-
if (!data || checking || saving) {
235+
if (!data || saving) {
249236
return
250237
}
251-
setChecking(true)
252-
setCheckResult(null)
253-
setSaveError(null)
254-
setSaveMessage(null) // don't leave a stale Save success banner beside a new check result
255-
apiService
256-
.getAxiosInstance()
257-
.post<CheckStatusResponse>(`/v1/schedule1/check-status?millId=${millId}&year=${year}`)
258-
.then((response) => {
259-
setCheckResult(response.data)
260-
})
261-
.catch((error: unknown) => {
262-
setSaveError(extractDetail(error) || 'Unable to check status.')
263-
})
264-
.finally(() => setChecking(false))
238+
clearBanners() // don't leave a stale Save success banner beside a new check result
239+
checkStatus<CheckStatusResponse>({
240+
fallback: 'Unable to check status.',
241+
onSuccess: setCheckResult,
242+
})
265243
}
266244

267245
const handleOtherCosts = () => {
@@ -541,7 +519,9 @@ const Schedule1: FC = () => {
541519
className="schedule-1__actions"
542520
editable={editable}
543521
saving={saving}
544-
checking={checking}
522+
// Check Status now shares the single `saving` lock (it runs through the same run()); there is no
523+
// separate checking flag, so both Save and Check disable together while any write is in flight.
524+
checking={false}
545525
onSave={handleSave}
546526
onCheckStatus={handleCheckStatus}
547527
onDelete={() => setConfirmDeleteOpen(true)}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import apiService from '@/service/api-service'
2+
import { useScheduleBanners } from '@/hooks/useScheduleBanners'
3+
4+
const api = () => apiService.getAxiosInstance()
5+
6+
type UseScheduleMutationsOptions = {
7+
/** API base path, e.g. {@code '/v1/schedule1'}; mill/year are appended as query params. */
8+
readonly path: string
9+
readonly millId: number | null
10+
readonly year: number | null
11+
/** True while the render's mill/year still matches the live context — the stale-response guard. */
12+
readonly isCurrent: () => boolean
13+
}
14+
15+
type MutationOptions<T> = {
16+
/** Applied only when the request resolves under the still-current context (see {@code run}). */
17+
readonly onSuccess: (data: T) => void
18+
/** Shown only when the rejection carries no ProblemDetail detail of its own (AD-8). */
19+
readonly fallback: string
20+
/** Appended to the base path before the mill/year query, e.g. {@code '/records/12'} for a by-id write. */
21+
readonly suffix?: string
22+
}
23+
24+
/**
25+
* The shared save / delete / check-status concern for the schedule pages. Composes
26+
* {@link useScheduleBanners} — so a page gets the banner + in-flight state AND the mutation helpers
27+
* from one hook — and routes every write through its {@code run()}, which already carries the
28+
* {@code isCurrent()} guard that stops a stale in-flight write from repainting a newly-switched
29+
* mill/year context (proven on Schedules 7A/7B/9).
30+
*
31+
* <p>Pages supply only their {@code path} (and, per call, their {@code validateScheduleN} + the
32+
* {@code onSuccess} that applies the echoed document). The request/error/lock scaffolding lives here
33+
* and in {@code run()}, not re-hand-rolled per page. Delete deliberately takes its {@code onSuccess}
34+
* at the call site: single-document pages whose re-GET would 404 (Schedules 1/3) reset to an empty
35+
* read-only shape in place, while list pages (4/5/8) re-seed from the reload — a genuine per-page
36+
* empty-state difference documented at the call site rather than forked into this hook.
37+
*
38+
* <p>Like {@code run()}, this hook is intentionally NOT memoized: its helpers close over the render's
39+
* mill/year so each dispatch carries the current {@code isCurrent}.
40+
*/
41+
export function useScheduleMutations<TCheckResult>({
42+
path,
43+
millId,
44+
year,
45+
isCurrent,
46+
}: UseScheduleMutationsOptions) {
47+
const banners = useScheduleBanners<TCheckResult>(isCurrent)
48+
49+
const query = `?millId=${String(millId)}&year=${String(year)}`
50+
const url = (suffix = '') => `${path}${suffix}${query}`
51+
52+
/** PUT (default) or POST a body, then apply {@code onSuccess} under the guarded {@code run()}. */
53+
const save = <T>(
54+
body: unknown,
55+
{
56+
onSuccess,
57+
fallback,
58+
suffix,
59+
method = 'put',
60+
}: MutationOptions<T> & { method?: 'put' | 'post' },
61+
) =>
62+
banners.run<T>(
63+
method === 'post' ? api().post<T>(url(suffix), body) : api().put<T>(url(suffix), body),
64+
{ fallback, onSuccess },
65+
)
66+
67+
/** DELETE, then apply the page's {@code onSuccess} (its own post-delete empty-state) under {@code run()}. */
68+
const remove = <T>({ onSuccess, fallback, suffix }: MutationOptions<T>) =>
69+
banners.run<T>(api().delete<T>(url(suffix)), { fallback, onSuccess })
70+
71+
/** POST the check-status endpoint (default suffix {@code '/check-status'}). */
72+
const checkStatus = <T>({ onSuccess, fallback, suffix = '/check-status' }: MutationOptions<T>) =>
73+
banners.run<T>(api().post<T>(url(suffix)), { fallback, onSuccess })
74+
75+
return { ...banners, query, url, save, remove, checkStatus }
76+
}
77+
78+
export default useScheduleMutations

0 commit comments

Comments
 (0)