diff --git a/src/api/api.ts b/src/api/api.ts index 51897b61..3cb8a7f0 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -3,18 +3,8 @@ import { env } from "@/lib/env"; import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url"; import { API_PATHS } from "@/api/api-paths"; import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error"; -import queryClient from "@/lib/query/query-client"; -import { stopQueryBroadcast } from "@/lib/query/setup-query-broadcast"; -import { clearAuth, getAccessToken } from "@/lib/auth/auth-storage"; - -function clearSessionAndRedirectToLogin(): void { - clearAuth(); - stopQueryBroadcast(queryClient); - - if (typeof window !== "undefined" && window.location.pathname !== "/login") { - window.location.replace("/login"); - } -} +import { getAccessToken } from "@/lib/auth/auth-storage"; +import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); @@ -51,7 +41,7 @@ export const api: KyInstance = ky.create({ return response; } - clearSessionAndRedirectToLogin(); + expireSessionAndRedirectToLogin(); return response; }, ], diff --git a/src/api/upload-form-with-progress.ts b/src/api/upload-form-with-progress.ts new file mode 100644 index 00000000..90a2a254 --- /dev/null +++ b/src/api/upload-form-with-progress.ts @@ -0,0 +1,103 @@ +import { getAccessToken } from "@/lib/auth/auth-storage"; +import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; +import { env } from "@/lib/env"; +import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url"; + +export interface UploadFormResult { + ok: boolean; + status: number; + data: unknown; +} + +export interface UploadFormOptions { + signal?: AbortSignal; + onProgress?: (percent: number) => void; +} + +function parseJsonBody(text: string): unknown { + if (!text.trim()) { + return undefined; + } + + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function createAbortError(): DOMException { + return new DOMException("The upload was aborted.", "AbortError"); +} + +/** + * hazard: Ky's `onUploadProgress` turns the body into a ReadableStream, and browsers only accept + * streamed request bodies over HTTP/2 — on HTTP/1.1 (Vite proxy, plain-HTTP hosts) the request dies + * as a network error before reaching the API. XHR reports progress on every transport. + */ +export function uploadFormWithProgress( + path: string, + body: FormData, + options?: UploadFormOptions, +): Promise { + return new Promise((resolve, reject) => { + if (options?.signal?.aborted) { + reject(createAbortError()); + return; + } + + const request = new XMLHttpRequest(); + request.open("POST", `${resolveApiBaseUrl(env.apiUrl)}/${path.replace(/^\/+/, "")}`); + request.setRequestHeader("Accept", "application/json"); + + const token = getAccessToken(); + if (token) { + request.setRequestHeader("Authorization", token); + } + + const abortRequest = () => { + request.abort(); + }; + options?.signal?.addEventListener("abort", abortRequest); + + const detachSignal = () => { + options?.signal?.removeEventListener("abort", abortRequest); + }; + + const { onProgress } = options ?? {}; + if (onProgress) { + request.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress(Math.round((event.loaded / event.total) * 100)); + } + }); + } + + request.addEventListener("load", () => { + detachSignal(); + const { status } = request; + + if (status === 401) { + expireSessionAndRedirectToLogin(); + } + + resolve({ + ok: status >= 200 && status < 300, + status, + data: parseJsonBody(request.responseText), + }); + }); + + request.addEventListener("error", () => { + detachSignal(); + reject(new Error("Upload failed. Check your connection and try again.")); + }); + + request.addEventListener("abort", () => { + detachSignal(); + reject(createAbortError()); + }); + + request.send(body); + }); +} diff --git a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts index 94b60262..346ecb57 100644 --- a/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts +++ b/src/app/(protected)/workorders/_components/detail/use-slide-over-media-state.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "react-toastify"; import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session"; @@ -9,9 +9,13 @@ import type { useDeleteWorkOrderMedia, useUpdateWorkOrderMediaCategory, useUploadWorkOrderMedia, + UploadWorkOrderMediaInput, } from "@/domain/work-orders/use-cases/use-work-order-mutations"; - -const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]); +import { + isCompletionSurfaceMedia, + isExtrasSurfaceMedia, + type MediaSurfaceOwnership, +} from "@/domain/work-orders/utils/partition-slide-over-media"; type UseSlideOverMediaStateArgs = { detailForRow?: WorkOrderBoardDetail; @@ -20,35 +24,108 @@ type UseSlideOverMediaStateArgs = { categorizeMediaMutation: ReturnType; }; +function addOwnedId(previous: Set, id: string | undefined): Set { + if (!id) return previous; + const next = new Set(previous); + next.add(id); + return next; +} + +function dropOwnedId(previous: Set, id: string): Set { + if (!previous.has(id)) return previous; + const next = new Set(previous); + next.delete(id); + return next; +} + +const OWNERSHIP_STORAGE_PREFIX = "work-order-media-surface:"; + +function loadOwnership(workOrderId: string | number | undefined): MediaSurfaceOwnership { + if (workOrderId == null) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() }; + try { + const stored = sessionStorage.getItem(`${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`); + if (!stored) return { completionOwnedIds: new Set(), extraOwnedIds: new Set() }; + const parsed = JSON.parse(stored) as { completion?: unknown; extra?: unknown }; + return { + completionOwnedIds: new Set(Array.isArray(parsed.completion) ? parsed.completion : []), + extraOwnedIds: new Set(Array.isArray(parsed.extra) ? parsed.extra : []), + }; + } catch { + return { completionOwnedIds: new Set(), extraOwnedIds: new Set() }; + } +} + +function saveOwnership( + workOrderId: string | number | undefined, + ownership: MediaSurfaceOwnership, +): void { + if (workOrderId == null) return; + try { + sessionStorage.setItem( + `${OWNERSHIP_STORAGE_PREFIX}${workOrderId}`, + JSON.stringify({ + completion: [...ownership.completionOwnedIds], + extra: [...ownership.extraOwnedIds], + }), + ); + } catch { + return; + } +} + export function useSlideOverMediaState({ detailForRow, uploadMediaMutation, deleteMediaMutation, categorizeMediaMutation, }: UseSlideOverMediaStateArgs) { + const workOrderId = detailForRow?.info.id; const [mediaBatchPending, setMediaBatchPending] = useState(false); + const [ownership, setOwnershipState] = useState(() => + loadOwnership(workOrderId), + ); + + useEffect(() => { + setOwnershipState(loadOwnership(workOrderId)); + }, [workOrderId]); + + const updateOwnership = useCallback( + (update: (current: MediaSurfaceOwnership) => MediaSurfaceOwnership) => { + setOwnershipState((current) => { + const next = update(current); + saveOwnership(workOrderId, next); + return next; + }); + }, + [workOrderId], + ); + + const persistCompletionUpload = useCallback( + async (input: UploadWorkOrderMediaInput) => { + const item = await uploadMediaMutation.mutateAsync(input); + updateOwnership((current) => ({ + completionOwnedIds: addOwnedId(new Set(current.completionOwnedIds), item.id), + extraOwnedIds: dropOwnedId(new Set(current.extraOwnedIds), item.id), + })); + return item; + }, + [updateOwnership, uploadMediaMutation], + ); + const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } = - useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input)); + useMediaUploadSession(detailForRow, persistCompletionUpload); const completionMedia = useMemo( - () => - uploads.filter( - (item) => - COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") || - item.uploadStatus === "uploading" || - item.uploadStatus === "failed", - ), - [uploads], + () => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)), + [uploads, ownership], ); const extrasUploads = useMemo( () => - boardMediaItemsToMediaFiles( - (detailForRow?.media ?? []).filter( - (item) => item.category === "Extra" || item.category === "Aveta", - ), + boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) => + isExtrasSurfaceMedia(item, ownership), ), - [detailForRow?.media], + [detailForRow?.media, ownership], ); const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading"); @@ -68,7 +145,11 @@ export function useSlideOverMediaState({ try { for (const file of files) { // Upload stays Extra; Aveta is applied only by an explicit Extra Docs PATCH. - await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + const item = await uploadMediaMutation.mutateAsync({ file, category: "Extra" }); + updateOwnership((current) => ({ + completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), item.id), + extraOwnedIds: addOwnedId(new Set(current.extraOwnedIds), item.id), + })); } } finally { setMediaBatchPending(false); @@ -81,7 +162,20 @@ export function useSlideOverMediaState({ toast.error("Missing work order version. Refresh and try again."); return; } - categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion }); + categorizeMediaMutation.mutate( + { mediaId, category, workOrderVersion }, + { + onSuccess: () => { + updateOwnership((current) => ({ + completionOwnedIds: dropOwnedId(new Set(current.completionOwnedIds), mediaId), + extraOwnedIds: + category === "Extra" + ? addOwnedId(new Set(current.extraOwnedIds), mediaId) + : dropOwnedId(new Set(current.extraOwnedIds), mediaId), + })); + }, + }, + ); }; const removeMedia = (id: string) => { diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts new file mode 100644 index 00000000..cf3624c9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-form.ts @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useState } from "react"; +import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; +import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; +import { useCreateVendor } from "@/domain/vendors/use-cases/use-create-vendor"; +import { + buildCompanyOptions, + buildTechnicianOptions, + phoneForOption, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; +import { + mergeVendorDropdownItems, + resolveVendorCompanyAnchor, +} from "@/app/(protected)/workorders/_components/list/table/cells/resolve-vendor-company-anchor"; +import { resolveTechnicianKey } from "@/app/(protected)/workorders/_components/list/table/cells/resolve-technician-key"; +import { useVendorDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save"; +import { computeVendorDialogCanSubmit } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-can-submit"; +import type { VendorPatch } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-types"; + +type UseVendorDialogFormArgs = { + open: boolean; + onOpenChange: (open: boolean) => void; + vendorId: string | number; + company: string; + tech: string; + techPhone: string; + vendors: VendorDropdownItem[]; + onSave: (patch: VendorPatch) => void; +}; + +export function useVendorDialogForm({ + open, + onOpenChange, + vendorId, + company, + tech, + techPhone, + vendors, + onSave, +}: UseVendorDialogFormArgs) { + const createVendor = useCreateVendor(); + const [selectedCompany, setSelectedCompany] = useState(company); + const [selectedKey, setSelectedKey] = useState(() => + resolveTechnicianKey(vendorId, tech, company, vendors), + ); + const [t, setT] = useState(tech); + const [phone, setPhone] = useState(techPhone); + const [notes, setNotes] = useState(""); + const [addedVendors, setAddedVendors] = useState([]); + const [pendingCreates, setPendingCreates] = useState>({}); + const [saveError, setSaveError] = useState(null); + + const allVendors = useMemo( + () => mergeVendorDropdownItems(vendors, addedVendors), + [vendors, addedVendors], + ); + + const companyOptions = useMemo( + () => buildCompanyOptions(allVendors, company), + [allVendors, company], + ); + + const technicianOptions = useMemo( + () => buildTechnicianOptions(allVendors, selectedCompany, tech, company), + [allVendors, selectedCompany, tech, company], + ); + + useEffect(() => { + if (open) { + const key = resolveTechnicianKey(vendorId, tech, company, vendors); + const initialOptions = buildTechnicianOptions(vendors, company, tech, company); + setSelectedCompany(company); + setSelectedKey(key); + setT(tech); + setPhone(techPhone || phoneForOption(tech, initialOptions)); + setNotes(""); + setAddedVendors([]); + setPendingCreates({}); + setSaveError(null); + } + // invariant: rebuilding options after Add must not clear staged technician drafts. + }, [open, tech, techPhone, vendors, company, vendorId]); + + const selected = technicianOptions.find((option) => option.key === selectedKey); + const resolvedCompany = selectedCompany; + const selectedVendor = + selected?.vendor ?? allVendors.find((vendor) => vendor.companyName === selectedCompany); + + const { companyName: anchorCompanyName, companyVendor } = resolveVendorCompanyAnchor({ + company: selectedCompany, + resolvedCompany, + selectedVendor, + vendors: allVendors, + }); + + const canSubmit = computeVendorDialogCanSubmit({ + current: { + company: selectedCompany, + selectedKey, + phone, + notes, + pendingCreateCount: Object.keys(pendingCreates).length, + }, + baseline: { + company, + selectedKey: resolveTechnicianKey(vendorId, tech, company, vendors), + phone: + techPhone || phoneForOption(tech, buildTechnicianOptions(vendors, company, tech, company)), + notes: "", + pendingCreateCount: 0, + }, + }); + + const { handleSave } = useVendorDialogSave({ + allVendors, + pendingCreates, + selectedKey, + selectedVendor, + resolvedCompany, + anchorCompanyName, + tech: selected?.name ?? t, + phone, + createVendor, + onSave, + onOpenChange, + setSaveError, + canSubmit, + }); + + return { + companyOptions, + technicianOptions, + selected, + t, + phone, + notes, + resolvedCompany, + selectedVendor, + selectedCompany, + selectedKey, + anchorCompanyName, + companyVendor, + saveError, + canSubmit, + isPending: createVendor.isPending, + handleSave, + handleCompanyPick: (nextCompany: string) => { + const selectedOption = technicianOptions.find((option) => option.key === selectedKey); + setSelectedCompany(nextCompany); + if (selectedOption?.company !== nextCompany) { + setAddedVendors([]); + setPendingCreates({}); + setSelectedKey(""); + setT(""); + setPhone(""); + } + }, + handlePick: (key: string) => { + const option = technicianOptions.find((o) => o.key === key); + setSelectedKey(key); + setT(option?.name ?? key); + setPhone(option?.phone || phoneForOption(option?.name ?? key, technicianOptions)); + }, + handleTechnicianCreated: ( + created: VendorDropdownItem, + createdPhone: string, + payload: VendorFormValues, + ) => { + setAddedVendors((current) => [...current, created]); + setPendingCreates((current) => ({ ...current, [String(created.id)]: payload })); + setSelectedKey(String(created.id)); + setT(created.contactName); + setPhone(createdPhone); + }, + setPhone, + setNotes, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts index 502714c6..619bc6c9 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-vendor-dialog-save.ts @@ -18,6 +18,7 @@ type UseVendorDialogSaveArgs = { onSave: (patch: VendorPatch) => void; onOpenChange: (open: boolean) => void; setSaveError: (error: string | null) => void; + canSubmit: boolean; }; export function useVendorDialogSave({ @@ -33,6 +34,7 @@ export function useVendorDialogSave({ onSave, onOpenChange, setSaveError, + canSubmit, }: UseVendorDialogSaveArgs) { const resolveSaveVendor = () => allVendors.find((vendor) => String(vendor.id) === selectedKey) ?? @@ -44,6 +46,7 @@ export function useVendorDialogSave({ selectedVendor; const handleSave = async () => { + if (!canSubmit) return; setSaveError(null); let saveVendor = resolveSaveVendor(); const pendingPayload = saveVendor ? pendingCreates[String(saveVendor.id)] : undefined; @@ -65,8 +68,8 @@ export function useVendorDialogSave({ onSave({ vendorId: saveVendor ? String(saveVendor.id) : "", company: resolvedCompany || anchorCompanyName, - tech: saveVendor?.contactName ?? tech, - techPhone: phone, + tech: selectedKey ? (saveVendor?.contactName ?? tech) : "", + techPhone: selectedKey ? phone : "", }); onOpenChange(false); }; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx index 5b58de5e..928bfe51 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-body.tsx @@ -1,4 +1,4 @@ -import { Wrench } from "lucide-react"; +import { Building2, Wrench } from "lucide-react"; import { DLG_INPUT_CLS, DLG_INPUT_STYLE, @@ -10,9 +10,14 @@ import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/ import { VendorDialogAddTechnician } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-add-technician"; import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card"; import type { VendorFormValues } from "@/domain/vendors/schemas/vendor-schema"; -import type { TechnicianOption } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; +import type { + CompanyOption, + TechnicianOption, +} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options"; type VendorDialogBodyProps = { + selectedCompany: string; + companyOptions: CompanyOption[]; selectedKey: string; technicianOptions: TechnicianOption[]; anchorCompanyName: string; @@ -23,6 +28,7 @@ type VendorDialogBodyProps = { resolvedCompany: string; selectedVendor: VendorDropdownItem | undefined; saveError: string | null; + onCompanyPick: (company: string) => void; onPick: (key: string) => void; onPhoneChange: (phone: string) => void; onNotesChange: (notes: string) => void; @@ -34,6 +40,8 @@ type VendorDialogBodyProps = { }; export function VendorDialogBody({ + selectedCompany, + companyOptions, selectedKey, technicianOptions, anchorCompanyName, @@ -44,6 +52,7 @@ export function VendorDialogBody({ resolvedCompany, selectedVendor, saveError, + onCompanyPick, onPick, onPhoneChange, onNotesChange, @@ -53,17 +62,46 @@ export function VendorDialogBody({ <>
- Technician + Company + + } + searchPlaceholder="Search company…" + options={companyOptions.map((option) => ({ + key: option.key, + label: option.label, + }))} + onPick={onCompanyPick} + /> +
+ + {Boolean(resolvedCompany) && ( +
+ Company info + +
+ )} + +
+ Technician (optional) } searchPlaceholder="Search technician…" options={technicianOptions.map((option) => ({ key: option.key, - label: option.label, + label: option.name, }))} onPick={onPick} /> @@ -80,7 +118,7 @@ export function VendorDialogBody({ {Boolean(tech) && (
- Technician Phone + Technician Phone (optional) )} - {Boolean(resolvedCompany) && ( -
- Company - -
- )} -
- Notes + Notes (optional)