diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index d175e6c5..37db2b85 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -37,6 +37,8 @@ export const API_PATHS = { media: (id: string | number) => `workorders/${id}/media`, mediaItem: (id: string | number, mediaId: string | number) => `workorders/${id}/media/${mediaId}`, + mediaContent: (id: string | number, mediaId: string | number) => + `workorders/${id}/media/${mediaId}/content`, completionDoc: (id: string | number) => `workorders/${id}/completion-doc`, uplifts: (id: string | number) => `workorders/${id}/uplifts`, upliftCancel: (workOrderId: string | number, upliftId: string | number) => diff --git a/src/api/api.ts b/src/api/api.ts index 44f40aee..147c0571 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -3,20 +3,10 @@ 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"; +import { getAccessToken } from "@/lib/auth/auth-storage"; +import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; import { traceHttpOperation } from "@/observability/http-transaction"; -function clearSessionAndRedirectToLogin(): void { - clearAuth(); - stopQueryBroadcast(queryClient); - - if (typeof window !== "undefined" && window.location.pathname !== "/login") { - window.location.replace("/login"); - } -} - function isAuthLoginRequest(request: Request): boolean { return request.url.includes(API_PATHS.authentication.login); } @@ -52,7 +42,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/tabs/slide-over-extras-tab.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx index a6df25e5..eee8b3ec 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { toast } from "react-toastify"; import { Text } from "@/components/ui/text"; +import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api"; import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media"; import { @@ -20,9 +21,9 @@ import { import { canDeleteWorkOrderMedia, formatMediaSizeLimit, + isWorkOrderExtraDocTypeAllowed, isWorkOrderFileTooLarge, - isWorkOrderMediaTypeAllowed, - workOrderMediaAcceptAttribute, + workOrderExtraDocsAcceptAttribute, } from "@/domain/work-orders/utils/work-order-media-limits"; const FILETYPE_ICON: Record = { @@ -43,21 +44,43 @@ type SlideOverExtrasTabProps = { isReadOnly: boolean; pending?: boolean; avetaRequired?: boolean; + workOrderId?: string | number; onUpload: (files: File[]) => void; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }; +async function openExtraDocContent(workOrderId: string | number, mediaId: string): Promise { + const tab = window.open("about:blank", "_blank"); + if (!tab) { + toast.error("Unable to open this document. Please allow pop-ups and try again."); + return; + } + + try { + const blob = await workOrdersApi.getMediaContent(workOrderId, mediaId); + const objectUrl = URL.createObjectURL(blob); + tab.opener = null; + tab.location.href = objectUrl; + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + } catch { + tab.close(); + toast.error("Unable to open this document."); + } +} + function ExtraDocRow({ file, isReadOnly, pending, + workOrderId, onRemove, onCategorize, }: { file: MediaFile; isReadOnly: boolean; pending: boolean; + workOrderId?: string | number; onRemove: (id: string) => void; onCategorize?: (id: string, category: UploadableMediaCategory) => void; }) { @@ -75,15 +98,20 @@ function ExtraDocRow({ className="shrink-0" style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }} /> - { + if (workOrderId == null) { + return; + } + void openExtraDocContent(workOrderId, file.id); + }} + className="min-w-0 flex-1 truncate text-left hover:underline disabled:cursor-not-allowed disabled:opacity-50" style={{ fontSize: 12, color: "var(--foreground)" }} > {file.name} - + {showCategory ? ( diff --git a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx index ce70990a..c5955f15 100644 --- a/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx +++ b/src/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab-edit-view.tsx @@ -16,6 +16,7 @@ import type { } from "@/domain/work-orders/types/work-order"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard"; +import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils"; import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { DocBadge } from "./slide-over-doc-badge"; @@ -31,6 +32,75 @@ type SlideOverInfoTabEditViewProps = { onRequestComplete?: () => void; }; +function SlideOverScheduleOnField({ + draft, + onDraftChange, +}: { + draft: WorkOrderTableRow; + onDraftChange: (patch: Partial) => void; +}) { + const hasSchedule = + Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd); + + return ( + + onDraftChange({ + scheduledOn, + scheduleWeekOnly: false, + targetWeek: scheduledOn ? "" : draft.targetWeek, + scheduleEnd: "", + schedMode: "date", + isPastDue: false, + ...deriveDayFields(scheduledOn), + }) + } + onSaveWeek={(weekStart) => + onDraftChange({ + scheduledOn: "", + targetWeek: weekStart, + scheduleWeekOnly: true, + scheduleEnd: "", + schedMode: "week", + isPastDue: false, + ...deriveDayFields(""), + }) + } + onSaveRange={(start, end) => + onDraftChange({ + scheduledOn: start, + scheduleEnd: end, + schedMode: "range", + scheduleWeekOnly: false, + targetWeek: "", + isPastDue: false, + ...deriveDayFields(start), + }) + } + onClear={ + hasSchedule + ? () => + onDraftChange({ + scheduledOn: "", + scheduleWeekOnly: false, + targetWeek: "", + scheduleEnd: "", + schedMode: undefined, + isPastDue: false, + ...deriveDayFields(""), + }) + : undefined + } + /> + ); +} + export function SlideOverInfoTabEditView({ draft, users, @@ -99,23 +169,7 @@ export function SlideOverInfoTabEditView({ /> - - onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false }) - } - onSaveWeek={(weekStart) => - onDraftChange({ - scheduledOn: "", - targetWeek: weekStart, - scheduleWeekOnly: true, - isPastDue: false, - }) - } - /> + ; }; +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/detail/work-order-slide-over-tabs.tsx b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx index ebd41392..d43d94c1 100644 --- a/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx +++ b/src/app/(protected)/workorders/_components/detail/work-order-slide-over-tabs.tsx @@ -147,6 +147,7 @@ export function WorkOrderSlideOverTabs({ > ) : ( - + - Select dispatcher + Select dispatcher )} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell-value.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell-value.tsx new file mode 100644 index 00000000..7f9a35d9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell-value.tsx @@ -0,0 +1,60 @@ +import { CalendarDays, Calendar as CalendarIcon, CalendarRange } from "lucide-react"; +import { fmtDateRange, fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils"; +import { HighlightText } from "./highlight-text"; + +type DateCellValueArgs = { + showRange: boolean; + showWeek: boolean; + value: string; + scheduleEnd: string; + targetWeek: string; + q?: string; + highlight?: boolean; +}; + +export function DateCellValue({ + showRange, + showWeek, + value, + scheduleEnd, + targetWeek, + q, + highlight, +}: DateCellValueArgs) { + if (showRange) { + return ( + <> + + + {fmtDateRange(value, scheduleEnd)} + + + ); + } + if (showWeek) { + return ( + <> + + + {fmtWeekOf(targetWeek)} + + + ); + } + return ( + <> + + {value ? ( + + + + ) : ( + — + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx index e0932926..12b5a42e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -1,10 +1,13 @@ import { useState } from "react"; -import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; import { Popover } from "@mui/material"; +import { DateCellValue } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell-value"; import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker"; -import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils"; -import { HighlightText } from "./highlight-text"; +import { useWizardDateFieldState } from "@/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state"; +import { WizardDateModeTabs } from "@/app/(protected)/workorders/_components/wizard/wizard-date-mode-tabs"; +import { WizardDateRangePanel } from "@/app/(protected)/workorders/_components/wizard/wizard-date-range-panel"; +import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard"; type DateCellProps = { value: string; @@ -15,6 +18,9 @@ type DateCellProps = { targetWeek?: string; onSaveWeek?: (weekStart: string) => void; defaultMode?: "week" | "date"; + scheduleEnd?: string; + schedMode?: WizardSchedMode; + onSaveRange?: (start: string, end: string) => void; /** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */ onClear?: () => void; /** Screen-reader-only status appended inside the button so the accessible name keeps the visible date and adds durable state. */ @@ -30,14 +36,34 @@ export function DateCell({ targetWeek = "", onSaveWeek, defaultMode, + scheduleEnd = "", + schedMode, + onSaveRange, onClear, srStatusSuffix, }: DateCellProps) { const [anchorEl, setAnchorEl] = useState(null); - const [cellMode, setCellMode] = useState<"week" | "date">( - allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date", + const open = Boolean(anchorEl); + const allowRange = Boolean(allowWeekMode && onSaveRange); + const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState( + { + open, + value, + scheduleEnd, + allowRange, + allowWeekMode, + targetWeek, + schedMode, + defaultMode, + }, ); - const showWeek = allowWeekMode && !value && !!targetWeek; + const showWeek = Boolean(allowWeekMode && !value && targetWeek); + const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd); + const modes: WizardSchedMode[] = allowRange + ? ["week", "date", "range"] + : allowWeekMode + ? ["week", "date"] + : []; return ( <> @@ -60,29 +86,15 @@ export function DateCell({ } > - {showWeek ? ( - <> - - - {fmtWeekOf(targetWeek)} - - - ) : ( - <> - - {value ? ( - - - - ) : ( - — - )} - - )} + {Boolean(srStatusSuffix) && {srStatusSuffix}} setAnchorEl(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} onClick={(e) => e.stopPropagation()} slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} > - {Boolean(allowWeekMode) && ( -
- {(["week", "date"] as const).map((m) => { - const active = cellMode === m; - return ( - - ); - })} + {Boolean(onClear) && ( +
+
)} + {allowWeekMode && cellMode === "week" ? ( + ) : allowRange && cellMode === "range" ? ( + { + onSaveRange?.(start, end); + setAnchorEl(null); + }} + /> ) : ( )} - {Boolean(onClear) && ( -
- -
- )} ); diff --git a/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx index d78961af..656b5baf 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/severity-options-list.tsx @@ -71,6 +71,10 @@ export function SeverityField({ value, onSelect }: SeverityFieldProps) { sx={{ fontSize: 12, fontWeight: 600, marginBottom: "4px" }} > Severity (from APM) + + {" "} + * + void; onRequestComplete?: () => void; readOnly?: boolean; + statusOptions?: readonly WizardWOStatus[]; }; function ReadOnlyStatusLabel({ status }: { status: string }) { @@ -64,7 +68,7 @@ function PastDueStatusCell({ onPastDueAttempt?.(); setAnchorEl(e.currentTarget); }} - title="This WO missed its deadline. Update Schedule On to unlock status updates." + title={PAST_DUE_BADGE_TOOLTIP} className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80" style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }} > @@ -97,7 +101,7 @@ function PastDueStatusCell({ color: "var(--status-pending-text)", }} > - This work order is past due. Update Schedule On before the status can be updated. + {PAST_DUE_POPOVER_BODY}
@@ -124,6 +128,7 @@ export function StatusCell({ onPastDueAttempt, onRequestComplete, readOnly = false, + statusOptions = BOARD_INLINE_STATUSES, }: StatusCellProps) { const [anchorEl, setAnchorEl] = useState(null); const closabilityGaps = rowClosabilityGaps(wo); @@ -162,22 +167,19 @@ export function StatusCell({ onClose={() => setAnchorEl(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} onClick={(e) => e.stopPropagation()} - slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }} + slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }} > - {ALL_WIZARD_STATUSES.map((status) => { - const isCompletedBlocked = status === "Completed" && closabilityGaps.length > 0; - const missingTitle = isCompletedBlocked - ? formatClosabilityMissingTitle(closabilityGaps) - : undefined; + {statusOptions.map((status) => { + const option = getStatusOptionState(wo, status, closabilityGaps); return (