This commit is contained in:
Arthur Bassi 2026-09-28 19:26:20 +00:00 • committed by GitHub
commit e8c3a8ef4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
147 changed files with 2252 additions and 812 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 156 KiB

View file

@ -192,7 +192,6 @@ async function mockDashboardApi(page: Page, userRoles: string) {
}
if (pathname.includes("/Dashboard/Stats")) {
// The server reports no unassigned work for a dispatcher-scoped view.
const unassigned = userRoles === "Dispatcher" ? 0 : 11;
return fulfillJson(route, { data: { ...STATS, unassigned } });
}

View file

@ -46,6 +46,9 @@ export const API_PATHS = {
`workorders/${id}/media/${mediaId}/content`,
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
upliftEvidence: (id: string | number) => `workorders/${id}/uplift-evidence`,
upliftEvidenceStatus: (workOrderId: string | number, documentId: string | number) =>
`workorders/${workOrderId}/uplift-evidence/${documentId}`,
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
`workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>

View file

@ -27,7 +27,7 @@ function formatDays(value: number): string {
return (Math.round(value * 10) / 10).toLocaleString();
}
// SH-347: colour the completion rate green at 90+, amber 70–89, red below 70.
// why: SH-347: colour the completion rate green at 90+, amber 70–89, red below 70.
function completionRateColor(rate: number): string {
if (rate >= 90) return "success.main";
if (rate >= 70) return "warning.main";
@ -42,12 +42,10 @@ function DispatcherNameCell({
range: DashboardRangeKey;
}) {
const navigate = useNavigate();
// A listed dispatcher is one with work in range; the drill-down filters Work
// Orders by dispatcher + range and never uses the completion rate, so gate on
// identity rather than the rate. (A dispatcher with assigned work but a 0%
// rate was previously inert here while drillable from Workload.) The gate stays
// on identity rather than `assignedCount > 0` so a legacy response without the
// SH-347 count field (assignedCount → 0) keeps its rows drillable.
// why: A listed dispatcher is one with work in range; the drill-down filters Work Orders by dispatcher
// + range and never uses the completion rate, so gate on identity rather than the rate. (A
// dispatcher with assigned work but a 0% rate was previously inert here while drillable from
// Workload.) The gate stays on identity rather than `assignedCount > 0` so a legacy response without the SH-347 count field (assignedCount → 0) keeps its rows drillable.
const drillable = Boolean(row.dispatcherId);
if (!drillable) {

View file

@ -43,8 +43,8 @@ export function StatusDistributionCard({
label: row.status,
count: row.count,
}))}
// Lifecycle labels the board can filter on are drillable; the "Unknown"
// bucket (unparseable statuses) has no board filter, so leave it inert.
// why: Lifecycle labels the board can filter on are drillable; the "Unknown" bucket (unparseable
// statuses) has no board filter, so leave it inert.
isSelectable={(row) => DRILLABLE_STATUSES.has(row.id)}
onSelectRow={(row) =>
navigate(workOrderDrilldownUrl(workOrderStatusDrilldownSearch(row.id, range)))

View file

@ -40,9 +40,9 @@ export function useDashboardScope(): DashboardScopeState {
const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null;
// A range or scope change re-scopes both dispatcher tables, so their page must
// reset — otherwise a switch while on page 3 lands on an out-of-range empty
// page that reads as a false "no dispatchers" state.
// why: A range or scope change re-scopes both dispatcher tables, so their page must reset — otherwise a
// switch while on page 3 lands on an out-of-range empty page that reads as a false "no
// dispatchers" state.
const resetPages = () => {
setWorkloadPage(1);
setPerformancePage(1);
@ -64,8 +64,8 @@ export function useDashboardScope(): DashboardScopeState {
resetPages();
};
// A Dispatcher's forced own-WOs scope never triggers the reset affordance (only
// an active date range can, for that role); a Scheduler/Admin's non-All scope does.
// why: A Dispatcher's forced own-WOs scope never triggers the reset affordance (only an active date
// range can, for that role); a Scheduler/Admin's non-All scope does.
const scopeIsFiltered = canViewAll && scope.mode !== "all";
const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered;

View file

@ -28,9 +28,8 @@ export function WorkOrdersByRegionCard({
}: WorkOrdersByRegionCardProps) {
const navigate = useNavigate();
// SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet
// confirmed with the client ("do not implement until confirmed"), so only the
// four canonical zones are shown.
// why: SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet confirmed with the client
// ("do not implement until confirmed"), so only the four canonical zones are shown.
const zones = rows.filter((row) => row.region !== UNMAPPED_REGION_LABEL);
return (

View file

@ -51,10 +51,7 @@ export function SiteDeleteDialog({
refetch,
} = useSiteOpenWorkOrders(siteId, open);
const openCount = openWorkOrders?.count ?? 0;
// Without a current count the warning cannot be trusted, so Delete waits for a successful check.
// `isFetching` covers reopening: the count cached from the last open is stale until the refetch lands.
const countUnknown = isLoading || isFetching || isError;
// The server caps the id list; when it doesn't cover the stated count, open the site's whole board.
const openIds = openWorkOrders?.workOrderIds ?? [];
const openWorkOrdersHref =
openIds.length > 0 && openIds.length >= openCount

View file

@ -59,8 +59,6 @@ export function SiteFormFields({
value={addressField.value}
onInputChange={addressField.onChange}
onSelect={(parts) => {
// One pick fills all three, each through its own controller so the
// form marks them dirty and revalidates (edit mode's Save depends on it).
addressField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(normalizeStateCode(parts.state) ?? "");

View file

@ -13,8 +13,8 @@ export function openUpliftEvidence(row: UpliftQueueItem): void {
}
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
// Open the tab inside the click handler; a tab opened after the fetch resolves
// is treated as an unsolicited popup and blocked.
// why: Open the tab inside the click handler; a tab opened after the fetch resolves is treated as an
// unsolicited popup and blocked.
const tab = window.open("about:blank", "_blank");
if (tab != null) {
tab.opener = null;

View file

@ -30,8 +30,6 @@ const APPROVED_HEADER_CELLS = [
"Actions",
] as const;
// A rejection is terminal, so the archive ends with the decision and its reason
// instead of an Actions column.
const REJECTED_HEADER_CELLS = [
"Work Order",
"Service / Vendor",

View file

@ -295,8 +295,6 @@ function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
);
}
// A failed queue is not an empty one: the page's error alert explains it, so the
// empty-state copy must not also claim the list has nothing in it.
function UpliftApprovalsEmptyRow({
tab,
colSpan,

View file

@ -321,8 +321,8 @@ function RevokeModalAction({
}
function useExposureBreakdown(item: UpliftQueueItem) {
// Permission failures (e.g. 403 for account-scoped users) are handled quietly:
// the breakdown falls back to "Unavailable" instead of toasting.
// why: Permission failures (e.g. 403 for account-scoped users) are handled quietly: the breakdown falls
// back to "Unavailable" instead of toasting.
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
suppressErrorToast: true,
});

View file

@ -48,9 +48,6 @@ const TAB_LABELS: Record<UpliftApprovalTab, string> = {
rejected: "Rejected",
};
// A tab's total is only known once its queue has loaded at least once; until then the
// pill and pager must not claim a count (a loading or failed queue is not an empty one).
// A failed refetch keeps the last loaded page, so its total stays usable.
function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null {
return query.data ? query.data.totalCount : null;
}

View file

@ -131,7 +131,7 @@ export function useVendorRosterForm({
);
const { data: facets, isLoading: facetsLoading } = useVendorFacets();
const save = useSaveVendorCompanyRoster();
// Area assignment is Admin-only; the API enforces it and the field mirrors it.
// why: Area assignment is Admin-only; the API enforces it and the field mirrors it.
const canAssignArea = isAdminUser(useContext(AuthContext)?.user?.userRoles);
const [conflict, setConflict] = useState<VendorRosterConflict | null>(null);

View file

@ -40,8 +40,8 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo
city: roster.city,
state: roster.state,
zip: roster.zip,
// The deprecated stored Maps URL is intentionally not seeded: the form has
// no input for it, and carrying it would re-emit a legacy value on write.
// why: The deprecated stored Maps URL is intentionally not seeded: the form has no input for it, and
// carrying it would re-emit a legacy value on write.
notes: roster.notes,
areaId: roster.areaId,
technicians: roster.technicians.map((technician) => ({

View file

@ -119,9 +119,8 @@ function DrawerHeader({
}
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
// SH-271 replaced the separate "Open in Google Maps" row: the Street Address
// itself is the link now, and the location is derived from the structured
// parts rather than a stored URL.
// why: SH-271 replaced the separate "Open in Google Maps" row: the Street Address itself is the link
// now, and the location is derived from the structured parts rather than a stored URL.
const address = { street: roster.address, city: roster.city, state: roster.state };
const mapsUrl = mapsSearchUrl(address);

View file

@ -167,9 +167,9 @@ function CompanyFields({
value={streetField.value ?? ""}
onInputChange={streetField.onChange}
onSelect={(parts) => {
// One pick fills all three. City and State are written through
// their own controllers so react-hook-form marks them dirty and
// revalidates — a plain assignment would be discarded on save.
// why: One pick fills all three. City and State are written through their own controllers so
// react-hook-form marks them dirty and revalidates — a plain assignment would be discarded on
// save.
streetField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(normalizeStateCode(parts.state) ?? parts.state);

View file

@ -98,9 +98,9 @@ export function CompDocDialog({
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
// one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up
// Generate patch would carry the pre-upload version and 409 after a successful upload.
// why: The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in one
// transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up Generate
// patch would carry the pre-upload version and 409 after a successful upload.
const upload = useCompDocPdfUpload({
workOrderId: row.id,
rowVersion,

View file

@ -58,7 +58,6 @@ export function useCompDocPagination(
);
}, [blockCount]);
// Block content can change on any render (typing in the editor), so measure after each one.
useLayoutEffect(() => {
measure();
});
@ -81,12 +80,10 @@ export function useCompDocPagination(
onPageChangeRef.current?.({ current, total });
}, []);
// Any re-layout can move the sheet tops under the reader, even when the page count holds.
useLayoutEffect(() => {
report();
});
// Width changes, late font/image loads and reflow do not re-render; observe them.
const layoutKey = pages.map((page) => page.join(",")).join("|");
useEffect(() => {
const observer = new ResizeObserver(() => {

View file

@ -175,7 +175,7 @@ export function SlideOverCommentsTab({
);
const firstHighlightedId = comments.find((comment) => highlightedIds.has(String(comment.id)))?.id;
const firstHighlightedRef = useRef<HTMLDivElement | null>(null);
// Bring the first comment a notification pointed at into view whenever the target changes — the
// why: Bring the first comment a notification pointed at into view whenever the target changes — the
// tab may already be mounted when a later notification re-opens this same work order.
useEffect(() => {
const node = firstHighlightedRef.current;

View file

@ -192,15 +192,12 @@ export function SlideOverExtrasTab({
}: SlideOverExtrasTabProps) {
const inputRef = useRef<HTMLInputElement>(null);
const avetaAttached = hasExplicitAvetaFile(uploads);
// One selection at a time: a second pick during the async screen would read the
// same photo/video counts and could pass the per-work-order limit.
const screeningRef = useRef(false);
const [screening, setScreening] = useState(false);
const busy = pending || screening;
const addFiles = async (files: FileList | null) => {
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
// Snapshot synchronously: the input is reset right after this handler returns.
const selected = Array.from(files);
screeningRef.current = true;
setScreening(true);

View file

@ -0,0 +1,198 @@
import { useState, type ReactNode } from "react";
import { CircularProgress } from "@mui/material";
import { AlertTriangle, Ban } from "lucide-react";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { Text } from "@/components/ui/text";
import { CancelUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/cancel-uplift-dialog";
import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog";
import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form";
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift";
import {
useCancelWorkOrderUplift,
useCreateWorkOrderUplift,
useRevokeWorkOrderUplift,
useWorkOrderUplifts,
} from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import {
formatUpliftMoney,
getRemainingUpliftAllowance,
hasOpenWorkOrderUplift,
isWorkOrderUpliftsReadOnly,
upliftRevokeRequiresReason,
} from "@/domain/work-orders/utils/uplift-display-utils";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
type RevokeTarget = {
id: string | number;
status: WorkOrderUpliftStatus;
amount: number;
};
type SlideOverUpliftsTabProps = {
workOrderId: string | number;
workOrderType: string;
status: WorkOrderTableRow["status"];
onCreatePendingChange?: (pending: boolean) => void;
};
function revokeDialogDescription(amount: number | undefined): string {
const money = amount == null ? "" : `${formatUpliftMoney(amount)} `;
return `${money}goes back to your auto-approval allowance. This is recorded in the log.`;
}
function UpliftNotice({
background,
icon,
children,
}: {
background: string;
icon: ReactNode;
children: string;
}) {
return (
<div
className="flex items-start gap-2"
style={{ background, borderRadius: "var(--radius)", padding: "10px 12px" }}
>
{icon}
<Text
as="span"
variant="description"
sx={{ fontSize: 12.5, color: "var(--color-text-secondary)" }}
>
{children}
</Text>
</div>
);
}
export function SlideOverUpliftsTab({
workOrderId,
workOrderType,
status,
onCreatePendingChange,
}: SlideOverUpliftsTabProps) {
const { user } = useAuthContext();
const readOnly = isWorkOrderUpliftsReadOnly(status);
const { data: uplifts = [], isLoading, error, refetch } = useWorkOrderUplifts(workOrderId);
const createUplift = useCreateWorkOrderUplift(workOrderId);
const cancelUplift = useCancelWorkOrderUplift(workOrderId);
const revokeUplift = useRevokeWorkOrderUplift(workOrderId);
const [revokeTarget, setRevokeTarget] = useState<RevokeTarget | null>(null);
const [cancelTargetId, setCancelTargetId] = useState<string | number | null>(null);
const hasOpenRequest = hasOpenWorkOrderUplift(uplifts);
const showBody = !isLoading && !error;
const showEmpty = showBody && uplifts.length === 0;
const showForm = showBody && !readOnly && !hasOpenRequest;
const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending;
return (
<div className="flex flex-col gap-4">
{readOnly && (
<UpliftNotice
background="var(--color-bg-muted)"
icon={
<Ban
size={14}
aria-hidden
className="mt-px shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
>
This work order is closed. Uplifts can no longer be requested.
</UpliftNotice>
)}
{showBody && !readOnly && hasOpenRequest && (
<UpliftNotice
background="var(--status-pending-bg)"
icon={
<AlertTriangle
size={14}
aria-hidden
className="mt-px shrink-0"
style={{ color: "var(--warning)" }}
/>
}
>
A request is pending admin approval. Cancel it if you need to submit a different amount.
</UpliftNotice>
)}
{Boolean(error) && (
<QueryErrorPanel
compact
message={error instanceof Error ? error.message : "Failed to load uplifts."}
onRetry={() => void refetch()}
/>
)}
{isLoading && (
<div className="flex justify-center py-6">
<CircularProgress size={24} />
</div>
)}
{showForm && (
<WorkOrderUpliftCreateForm
pending={createUplift.isPending}
remainingAllowance={getRemainingUpliftAllowance(workOrderType, uplifts)}
onSubmit={async (input) => {
onCreatePendingChange?.(true);
try {
await createUplift.mutateAsync(input);
} finally {
onCreatePendingChange?.(false);
}
}}
/>
)}
{showBody &&
uplifts.map((uplift) => (
<WorkOrderUpliftListItem
key={String(uplift.id)}
uplift={uplift}
readOnly={readOnly}
currentUserId={user?.id}
currentUserIsAdmin={isAdminUser(user?.userRoles)}
pendingAction={actionPending}
onCancel={readOnly ? undefined : () => setCancelTargetId(uplift.id)}
onRevoke={() =>
setRevokeTarget({ id: uplift.id, status: uplift.status, amount: uplift.amount })
}
/>
))}
{showEmpty && (
<Text as="p" variant="description" tone="muted" className="py-6 text-center">
No uplifts on this work order.
</Text>
)}
<CancelUpliftDialog
open={cancelTargetId != null}
pending={cancelUplift.isPending}
onClose={() => setCancelTargetId(null)}
onConfirm={() => {
if (cancelTargetId == null) return;
cancelUplift.mutate(cancelTargetId, { onSuccess: () => setCancelTargetId(null) });
}}
/>
<RevokeUpliftDialog
open={revokeTarget != null}
pending={revokeUplift.isPending}
requireReason={
revokeTarget != null ? upliftRevokeRequiresReason(revokeTarget.status) : false
}
title="Revoke this uplift?"
description={revokeDialogDescription(revokeTarget?.amount)}
onClose={() => setRevokeTarget(null)}
onConfirm={(reason) => {
if (revokeTarget == null) return;
revokeUplift.mutate(
{ upliftId: revokeTarget.id, reason },
{ onSuccess: () => setRevokeTarget(null) },
);
}}
/>
</div>
);
}

View file

@ -90,13 +90,13 @@ export function useSlideOverEditState({
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
// why: Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row.
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
// why: Reset draft/baseline only when the opened WO identity or editMode intent changes — not on every
// optimistic parent-row field update during a failed/in-flight save.
useSlideOverInitialization({
row,
editMode,
@ -109,9 +109,9 @@ export function useSlideOverEditState({
setUnsavedIntent,
});
// Re-apply the requested tab on every open, including a second notification for the work order
// already on screen (same row id) where the identity effect above does not re-run. Tab-only so
// an in-flight edit draft is never discarded.
// why: Re-apply the requested tab on every open, including a second notification for the work order
// already on screen (same row id) where the identity effect above does not re-run. Tab-only so an
// in-flight edit draft is never discarded.
useEffect(() => {
if (!row) return;
setTab(editMode ? "info" : initialTab);

View file

@ -131,7 +131,6 @@ export function useSlideOverMediaState({
[detailForRow?.media, ownership, completionHasGallery],
);
// Per-work-order photo/video counts span both tabs, so count the whole session.
const mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]);
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");

View file

@ -26,15 +26,15 @@ import { useSlideOverMediaState } from "./use-slide-over-media-state";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type { SlideOverSaveOptions } from "./work-order-slide-over";
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
export type SlideOverTab = "info" | "comments" | "completion" | "uplifts" | "extras" | "audit";
type UseWorkOrderSlideOverArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
initialTab?: SlideOverTab;
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
openRequestKey?: number;
};
@ -56,10 +56,10 @@ function uploadSlideOverCompletionPdf({
toast.error("Missing work order version. Refresh and try again.");
return;
}
// The upload transitions the completion document to Yes and bumps the work-order row
// version in the same server transaction; the mutation's onSuccess refreshes the board.
// A client docStatus patch here would carry the pre-upload version and 409 after a
// successful upload, so it is intentionally omitted.
// why: The upload transitions the completion document to Yes and bumps the work-order row version in
// the same server transaction; the mutation's onSuccess refreshes the board. A client docStatus
// patch here would carry the pre-upload version and 409 after a successful upload, so it is
// intentionally omitted.
mutate({ file, workOrderVersion: version });
}
@ -89,10 +89,10 @@ function slideOverErrorMessage(error: unknown) {
export function useWorkOrderSlideOver({
row,
editMode,
initialTab = "info",
onSave,
onClose,
saving,
initialTab,
openRequestKey,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
@ -111,7 +111,7 @@ export function useWorkOrderSlideOver({
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
const [tab, setTab] = useState<SlideOverTab>("info");
const [tab, setTab] = useState<SlideOverTab>(initialTab);
const { detailForRow, infoSource } = resolveSlideOverSource(detail, row);
const readFlags = slideOverReadFlags(infoSource?.status);
@ -133,7 +133,8 @@ export function useWorkOrderSlideOver({
categorizeMediaMutation,
});
const completionPending = uploadCompletionMutation.isPending;
const closeDisabled = Boolean(saving || mediaPending || completionPending);
const [upliftCreatePending, setUpliftCreatePending] = useState(false);
const closeDisabled = Boolean(saving || mediaPending || completionPending || upliftCreatePending);
const editState = useSlideOverEditState({
row,
@ -146,7 +147,6 @@ export function useWorkOrderSlideOver({
initialTab,
openRequestKey,
});
const uploadCompletionPdf = (file: File) => {
uploadSlideOverCompletionPdf({
file,
@ -179,6 +179,8 @@ export function useWorkOrderSlideOver({
mediaPending,
completionPending,
closeDisabled,
upliftCreatePending,
setUpliftCreatePending,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,

View file

@ -3,9 +3,10 @@ import type { SlideOverTab } from "./use-work-order-slide-over";
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
{ id: "info", label: "Info" },
{ id: "comments", label: "Comments" },
{ id: "audit", label: "Audit Log" },
{ id: "completion", label: "Completion Doc" },
{ id: "uplifts", label: "Uplifts" },
{ id: "extras", label: "Extra Docs" },
{ id: "audit", label: "Audit Log" },
];
type WorkOrderSlideOverTabNavProps = {

View file

@ -4,6 +4,7 @@ import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/d
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
import { SlideOverUpliftsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-uplifts-tab";
import type {
LocationOption,
VendorDropdownItem,
@ -17,6 +18,31 @@ import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
function SlideOverUpliftsPanel({
tab,
infoSource,
upliftCreatePending,
onUpliftCreatePendingChange,
}: {
tab: SlideOverTab;
infoSource: WorkOrderTableRow | null | undefined;
upliftCreatePending: boolean;
onUpliftCreatePendingChange?: (pending: boolean) => void;
}) {
const visible = tab === "uplifts";
if (infoSource == null || (!visible && !upliftCreatePending)) return null;
return (
<div hidden={!visible}>
<SlideOverUpliftsTab
workOrderId={infoSource.id}
workOrderType={infoSource.type}
status={infoSource.status}
onCreatePendingChange={onUpliftCreatePendingChange}
/>
</div>
);
}
type WorkOrderSlideOverTabsProps = {
tab: SlideOverTab;
state: SlideOverState;
@ -141,6 +167,13 @@ export function WorkOrderSlideOverTabs({
</WorkOrderSlideOverTabPanel>
)}
<SlideOverUpliftsPanel
tab={tab}
infoSource={infoSource}
upliftCreatePending={Boolean(state.upliftCreatePending)}
onUpliftCreatePendingChange={state.setUpliftCreatePending}
/>
{tab === "extras" && (
<WorkOrderSlideOverTabPanel
variant="extras"

View file

@ -24,6 +24,7 @@ export type SlideOverSaveOptions = {
type WorkOrderSlideOverProps = {
row: WorkOrderTableRow | null;
editMode: boolean;
initialTab?: SlideOverTab;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
@ -36,7 +37,6 @@ type WorkOrderSlideOverProps = {
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void;
saving?: boolean;
initialTab?: SlideOverTab;
/** Comments a notification opened the slide-over for; highlighted on the Comments tab. */
commentHighlight?: CommentHighlight | null;
/** Increments on every open so re-opening the same work order re-applies tab/highlight. */
@ -46,6 +46,7 @@ type WorkOrderSlideOverProps = {
export function WorkOrderSlideOver({
row,
editMode,
initialTab = "info",
users,
sites,
vendors,
@ -58,7 +59,6 @@ export function WorkOrderSlideOver({
onDuplicateFound,
onClose,
saving,
initialTab,
commentHighlight,
openRequestKey,
}: WorkOrderSlideOverProps) {

View file

@ -1,52 +1,117 @@
import type { ReactNode } from "react";
import { Plus } from "lucide-react";
import type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift";
import {
getUpliftCellLabel,
getUpliftStatusPillStyle,
getUpliftCellVisual,
type UpliftCellVisual,
} from "@/domain/work-orders/utils/uplift-display-utils";
type UpliftCellProps = {
summary: UpliftSummary;
closed?: boolean;
onOpen?: () => void;
/** Closed work orders show a dash (not "Manage") that still opens the read-only history. */
readOnly?: boolean;
};
export function UpliftCell({ summary, onOpen, readOnly = false }: UpliftCellProps) {
const label = getUpliftCellLabel(summary);
const CELL_DOT: Record<"pending" | "approved" | "rejected", string> = {
pending: "var(--warning)",
approved: "var(--success)",
rejected: "var(--destructive)",
};
if (!onOpen) {
return label ? (
<span className="text-[11px] text-[var(--color-text-muted)]">{label}</span>
) : (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
);
}
const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus;
const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null;
const CELL_STATUS_COLOR: Record<"pending" | "approved" | "rejected", string> = {
pending: "var(--color-warning)",
approved: "var(--color-success)",
rejected: "var(--color-destructive)",
};
function UpliftCellButton({
label,
title,
onOpen,
children,
}: {
label: string;
title: string;
onOpen: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
aria-label={label}
title={title}
onClick={(event) => {
event.stopPropagation();
onOpen();
}}
title={label || readOnly ? "View uplifts" : "Manage uplifts"}
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
className="flex w-full items-center justify-start gap-1.5 text-left text-[11px] font-semibold whitespace-nowrap"
style={{ color: "var(--color-text-primary)" }}
>
{label && pillStyle ? (
<span
className="inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[11px] font-semibold whitespace-nowrap"
style={pillStyle}
>
{label}
</span>
) : readOnly ? (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
) : (
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
)}
{children}
</button>
);
}
function UpliftCellVisualBody({ visual }: { visual: UpliftCellVisual }) {
if (visual.kind === "plain") {
return <span>{visual.label}</span>;
}
const struck = visual.kind === "rejected";
return (
<>
<span
aria-hidden
className="inline-block shrink-0 rounded-full"
style={{ width: 7, height: 7, background: CELL_DOT[visual.kind] }}
/>
{visual.amount != null && (
<span
style={{
fontSize: 13,
fontWeight: struck ? 400 : 500,
color: struck ? "var(--color-text-muted)" : "var(--color-text-primary)",
textDecoration: struck ? "line-through" : "none",
}}
>
{visual.amount}
</span>
)}
<span style={{ fontSize: 11, color: CELL_STATUS_COLOR[visual.kind] }}>
{visual.statusLabel}
</span>
</>
);
}
export function UpliftCell({ summary, closed = false, onOpen }: UpliftCellProps) {
const label = getUpliftCellLabel(summary);
const visual = getUpliftCellVisual(summary);
if (!onOpen) {
return <span className="text-[11px] text-[var(--color-text-muted)]">{label ?? "—"}</span>;
}
if (!label && closed) {
return (
<UpliftCellButton label="View uplifts" title="No uplift" onOpen={onOpen}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 400 }}>—</span>
</UpliftCellButton>
);
}
if (!label) {
return (
<UpliftCellButton label="Add uplift" title="No uplift" onOpen={onOpen}>
<Plus size={13} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<span style={{ color: "var(--color-text-muted)", fontWeight: 400 }}>Add</span>
</UpliftCellButton>
);
}
return (
<UpliftCellButton label={label} title="View uplifts" onOpen={onOpen}>
{visual != null && <UpliftCellVisualBody visual={visual} />}
</UpliftCellButton>
);
}

View file

@ -11,7 +11,7 @@ export const WO_TABLE_COLS = [
{ key: "vendor", label: "VENDOR", w: 184 },
{ key: "appt", label: "APPT TIME", w: 128 },
{ key: "status", label: "STATUS", w: 148 },
// Completed Date gates the Completion Document, so it sits between Status and Comp Doc.
// why: Completed Date gates the Completion Document, so it sits between Status and Comp Doc.
{ key: "completed", label: "COMPLETED DATE", w: 130 },
{ key: "doc", label: "COMP DOC", w: 84 },
{ key: "uplift", label: "UPLIFT", w: 108 },

View file

@ -132,8 +132,8 @@ export function WoTableRowServiceCells({
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<UpliftCell
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
closed={isWorkOrderUpliftsReadOnly(row.status)}
onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined}
readOnly={isWorkOrderUpliftsReadOnly(row.status)}
/>
</td>
</>

View file

@ -1,24 +0,0 @@
import { WorkOrderUpliftsDialog } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrdersListOverlaysProps = {
activeUpliftsRow: WorkOrderTableRow | null;
onCloseUplifts: () => void;
};
export function WorkOrdersListOverlays({
activeUpliftsRow,
onCloseUplifts,
}: WorkOrdersListOverlaysProps) {
return (
<>
{activeUpliftsRow != null && (
<WorkOrderUpliftsDialog
row={activeUpliftsRow}
open={Boolean(activeUpliftsRow)}
onClose={onCloseUplifts}
/>
)}
</>
);
}

View file

@ -4,7 +4,6 @@ import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
import { WorkOrdersListWizard } from "@/app/(protected)/workorders/_components/list/work-orders-list-wizard";
import type { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
@ -108,11 +107,6 @@ export function WorkOrdersListPagePanels({ page }: WorkOrdersListPagePanelsProps
}}
/>
)}
<WorkOrdersListOverlays
activeUpliftsRow={page.activeUpliftsRow}
onCloseUplifts={() => page.setUpliftsRow(null)}
/>
</>
);
}

View file

@ -53,16 +53,12 @@ export function MediaUploader({
mediaCounts,
}: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
// One selection at a time: a second pick during the async screen would read the
// same photo/video counts and could pass the per-work-order limit.
const screeningRef = useRef(false);
const [screening, setScreening] = useState(false);
const addFiles = async (files: FileList | null) => {
if (readOnly || pending || screeningRef.current || !files || files.length === 0) return;
// Snapshot synchronously: the caller resets the input value right after this
// handler returns, which can detach a live FileList across awaits.
const selected = Array.from(files);
screeningRef.current = true;
setScreening(true);

View file

@ -0,0 +1,61 @@
import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { guardDialogClose } from "@/lib/dialog-guard";
type CancelUpliftDialogProps = {
open: boolean;
pending?: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function CancelUpliftDialog({
open,
pending = false,
onClose,
onConfirm,
}: CancelUpliftDialogProps) {
const handleClose = () => {
if (pending) return;
onClose();
};
return (
<Dialog open={open} onClose={guardDialogClose(pending, handleClose)} maxWidth="xs" fullWidth>
<DialogTitle
sx={{
fontFamily: "var(--font-display)",
fontSize: 18,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
Cancel uplift request?
</DialogTitle>
<DialogContent>
<Text as="p" variant="description" tone="muted">
This request will be withdrawn. You can submit a new one afterwards.
</Text>
</DialogContent>
<DialogActions sx={{ padding: "12px 20px", gap: 1 }}>
<Button
variant="ghost"
className="min-h-0 px-3 py-1 text-[12px]"
disabled={pending}
onClick={handleClose}
>
Keep request
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
disabled={pending}
onClick={onConfirm}
sx={{ background: "var(--color-warning)", color: "var(--color-text-inverse)" }}
>
Cancel request
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
import { Undo2 } from "lucide-react";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { Button } from "@/components/ui/button";
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
@ -22,8 +23,8 @@ export function RevokeUpliftDialog({
onConfirm,
pending = false,
requireReason = false,
title = "Revoke auto-approved uplift?",
description = "This will mark the uplift as revoked. Optional reason is stored with the decision.",
title = "Revoke this uplift?",
description = "goes back to your auto-approval allowance. This is recorded in the log.",
}: RevokeUpliftDialogProps) {
const [reason, setReason] = useState("");
@ -47,32 +48,43 @@ export function RevokeUpliftDialog({
return (
<Dialog open={open} onClose={guardDialogClose(pending, handleClose)} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogTitle
sx={{
fontFamily: "var(--font-display)",
fontSize: 18,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
{title}
</DialogTitle>
<DialogContent>
<Text as="p" variant="description" className="mb-3">
<Text as="p" variant="description" tone="muted" className="mb-3">
{description}
</Text>
<FieldLabel htmlFor="wo-revoke-uplift-reason" required={requireReason}>
Reason
{requireReason ? "Reason" : "Reason (optional)"}
</FieldLabel>
<TextField
id="wo-revoke-uplift-reason"
fullWidth
multiline
minRows={2}
placeholder="Wrong amount, wrong quote..."
value={reason}
required={requireReason}
onChange={(event) => setReason(event.target.value)}
/>
</DialogContent>
<DialogActions>
<DialogActions sx={{ padding: "12px 20px", gap: 1 }}>
<DialogCancelButton onClick={handleClose} disabled={pending} />
<Button
variant="destructive"
className="min-h-0 px-3 py-1 text-[12px]"
className="min-h-0 gap-1.5 px-3 py-1 text-[12px]"
disabled={pending || reasonMissing}
onClick={handleConfirm}
>
<Undo2 size={13} aria-hidden />
Revoke
</Button>
</DialogActions>

View file

@ -0,0 +1,108 @@
import { useRef } from "react";
import { Upload, X } from "lucide-react";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { Text } from "@/components/ui/text";
const ACCEPTED_TYPES = new Set(["application/pdf", "image/jpeg", "image/jpg", "image/png"]);
type UpliftAttachmentPickerProps = {
files: File[];
onChange: (files: File[]) => void;
};
function isAllowedUpliftEvidenceFile(file: File): boolean {
if (ACCEPTED_TYPES.has(file.type)) return true;
return /\.(pdf|jpe?g|png)$/i.test(file.name);
}
function allowedFiles(incoming: FileList | null): File[] {
if (!incoming || incoming.length === 0) return [];
return Array.from(incoming).filter(isAllowedUpliftEvidenceFile);
}
function UpliftStagedFile({ file, onRemove }: { file: File; onRemove: () => void }) {
return (
<div
className="flex items-center gap-2"
style={{
padding: "6px 8px",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-sm)",
}}
>
<Text as="span" variant="caption" className="min-w-0 flex-1 truncate">
{file.name}
</Text>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={onRemove}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
>
<X size={11} aria-hidden />
</button>
</div>
);
}
export function UpliftAttachmentPicker({ files, onChange }: UpliftAttachmentPickerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const stageFiles = (incoming: FileList | null) => {
const next = allowedFiles(incoming);
if (next.length === 0) return;
onChange([...files, ...next]);
};
return (
<div className="mt-4">
<FieldLabel>Attachment</FieldLabel>
<Text as="p" variant="caption" tone="muted" className="mb-2">
Optional — one or more PDF, JPG, or PNG files.
</Text>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
stageFiles(event.dataTransfer.files);
}}
className="flex w-full items-center justify-center gap-2 transition-colors hover:bg-(--color-bg-muted)"
style={{
minHeight: 64,
border: "1px dashed var(--color-border)",
borderRadius: "var(--radius)",
}}
>
<Upload size={14} aria-hidden style={{ color: "var(--color-text-muted)" }} />
<Text as="span" variant="caption" tone="muted">
Drag files here or click to browse
</Text>
</button>
<input
ref={inputRef}
type="file"
multiple
accept="application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png"
hidden
onChange={(event) => {
stageFiles(event.target.files);
event.target.value = "";
}}
/>
{files.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{files.map((file, index) => (
<UpliftStagedFile
key={`${file.name}-${file.lastModified}-${index}`}
file={file}
onRemove={() => onChange(files.filter((_, itemIndex) => itemIndex !== index))}
/>
))}
</div>
)}
</div>
);
}

View file

@ -1,23 +1,56 @@
import { useState } from "react";
import { Button, TextField } from "@mui/material";
import { AlertTriangle, Check, Plus } from "lucide-react";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { UpliftAttachmentPicker } from "@/app/(protected)/workorders/_components/uplifts/uplift-attachment-picker";
import { Text } from "@/components/ui/text";
import { formatUpliftMoney } from "@/domain/work-orders/utils/uplift-display-utils";
import {
upliftAllowanceHelper,
type UpliftAllowanceHelper,
type UpliftAllowanceHelperTone,
} from "@/domain/work-orders/utils/uplift-display-utils";
type WorkOrderUpliftCreateFormProps = {
pending?: boolean;
remainingAllowance: number;
onSubmit: (input: { amount: number; notes: string }) => void | Promise<void>;
onSubmit: (input: { amount: number; notes: string; files: File[] }) => void | Promise<unknown>;
};
function amountHelperText(parsedAmount: number, remainingAllowance: number): string {
const remaining = `Remaining auto-approval: ${formatUpliftMoney(remainingAllowance)}.`;
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
return remaining;
}
if (parsedAmount <= remainingAllowance) {
return `${remaining} This request will auto-approve.`;
}
return `${remaining} This request will go to the admin approval queue.`;
function canSubmitUplift(amount: number, notes: string): boolean {
return Number.isFinite(amount) && amount > 0 && notes.trim().length > 0;
}
const ALLOWANCE_HINT_COLOR: Record<UpliftAllowanceHelperTone, string> = {
muted: "var(--muted-foreground)",
success: "var(--status-completed-text)",
warning: "var(--warning)",
};
function UpliftAllowanceHint({ helper }: { helper: UpliftAllowanceHelper }) {
return (
<Text
as="p"
variant="caption"
className="flex items-center gap-1 whitespace-nowrap"
style={{ color: ALLOWANCE_HINT_COLOR[helper.tone] }}
sx={{
marginTop: "6px",
...(helper.tint
? {
background: "var(--status-pending-bg)",
borderRadius: "var(--radius-sm)",
padding: "3px 6px",
marginLeft: "-6px",
marginRight: "-6px",
}
: {}),
}}
>
{helper.icon === "check" && <Check size={12} aria-hidden className="shrink-0" />}
{helper.icon === "alert" && <AlertTriangle size={12} aria-hidden className="shrink-0" />}
{helper.text}
</Text>
);
}
export function WorkOrderUpliftCreateForm({
@ -27,57 +60,69 @@ export function WorkOrderUpliftCreateForm({
}: WorkOrderUpliftCreateFormProps) {
const [amount, setAmount] = useState("");
const [notes, setNotes] = useState("");
const [files, setFiles] = useState<File[]>([]);
const parsedAmount = Number(amount);
const canSubmit = Number.isFinite(parsedAmount) && parsedAmount > 0 && notes.trim().length > 0;
const helper = upliftAllowanceHelper(parsedAmount, remainingAllowance);
const canSubmit = canSubmitUplift(parsedAmount, notes);
const handleSubmit = async () => {
try {
await onSubmit({ amount: parsedAmount, notes: notes.trim() });
await onSubmit({ amount: parsedAmount, notes: notes.trim(), files });
setAmount("");
setNotes("");
setFiles([]);
} catch {
// Mutation onError already toasts; keep form values after failure.
// why: the mutation toasts onError, so a failed submit keeps the typed values.
}
};
return (
<div className="rounded-lg border border-[var(--color-border)] p-3">
<Text as="p" variant="label" className="mb-2">
Request uplift
</Text>
<div className="grid gap-2">
<div>
<FieldLabel htmlFor="wo-uplift-amount" required>
Amount ($)
</FieldLabel>
<TextField
id="wo-uplift-amount"
type="number"
size="small"
fullWidth
placeholder="0.00"
value={amount}
onChange={(event) => setAmount(event.target.value)}
slotProps={{ htmlInput: { min: 0, step: "0.01" } }}
/>
<UpliftAllowanceHint helper={helper} />
<div className="mt-4">
<FieldLabel htmlFor="wo-uplift-notes" required>
Notes
</FieldLabel>
<TextField
label="Amount"
type="number"
size="small"
value={amount}
onChange={(event) => setAmount(event.target.value)}
helperText={amountHelperText(parsedAmount, remainingAllowance)}
slotProps={{ htmlInput: { min: 0, step: "0.01" } }}
/>
<TextField
label="Notes"
id="wo-uplift-notes"
size="small"
fullWidth
multiline
minRows={2}
rows={3}
placeholder="Describe the additional work and why it is needed..."
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
<TextField
label="Attachments"
size="small"
disabled
helperText="Attachment upload will be wired when BE accepts files."
/>
</div>
<UpliftAttachmentPicker files={files} onChange={setFiles} />
<div className="mt-3 flex justify-end">
<Button
variant="contained"
size="small"
className="gap-1.5"
disabled={!canSubmit || pending}
onClick={() => {
void handleSubmit();
}}
>
Create uplift
<Plus size={13} aria-hidden />
Request uplift
</Button>
</div>
</div>

View file

@ -1,13 +1,42 @@
import { Button } from "@mui/material";
import { useSyncExternalStore } from "react";
import { Button, Tooltip } from "@mui/material";
import { FileText, Undo2 } from "lucide-react";
import { toast } from "react-toastify";
import { Text } from "@/components/ui/text";
import type { WorkOrderUplift } from "@/domain/work-orders/types/work-order-uplift";
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
import type {
WorkOrderUplift,
WorkOrderUpliftAttachment,
} from "@/domain/work-orders/types/work-order-uplift";
import {
canRevokeWorkOrderUplift,
formatUpliftCardMeta,
formatUpliftMoney,
formatUpliftStatusLabel,
getUpliftStatusPillStyle,
isVoidUpliftStatus,
} from "@/domain/work-orders/utils/uplift-display-utils";
const CLOSED_REVOKE_HINT = "This work order is closed. Uplifts can no longer be revoked.";
let upliftClock = Date.now();
function subscribeUpliftClock(onChange: () => void) {
const id = window.setInterval(() => {
upliftClock = Date.now();
onChange();
}, 60_000);
return () => window.clearInterval(id);
}
function readUpliftClock(): number {
return upliftClock;
}
function useUpliftClock(): number {
return useSyncExternalStore(subscribeUpliftClock, readUpliftClock, readUpliftClock);
}
type WorkOrderUpliftListItemProps = {
uplift: WorkOrderUplift;
readOnly: boolean;
@ -18,67 +47,203 @@ type WorkOrderUpliftListItemProps = {
onRevoke?: () => void;
};
export function WorkOrderUpliftListItem({
uplift,
readOnly,
currentUserId,
currentUserIsAdmin,
pendingAction = false,
onCancel,
onRevoke,
}: WorkOrderUpliftListItemProps) {
function UpliftStatusPill({ uplift }: { uplift: WorkOrderUplift }) {
const pillStyle = getUpliftStatusPillStyle(uplift.status);
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
const showRevoke =
!readOnly &&
canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) &&
Boolean(onRevoke);
return (
<div className="rounded-lg border border-[var(--color-border)] p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span
className="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={pillStyle}
<span
className="inline-flex shrink-0 items-center whitespace-nowrap"
style={{
fontSize: 11,
fontWeight: 500,
background: pillStyle.background,
color: pillStyle.color,
borderRadius: "var(--radius-sm)",
padding: "3px 8px",
}}
>
{formatUpliftStatusLabel(uplift.status)}
</span>
);
}
function UpliftFiledAttachment({
upliftId,
file,
}: {
upliftId: string | number;
file: WorkOrderUpliftAttachment;
}) {
const hasUrl = file.url !== "";
const downloadEvidence = () => {
const documentId = /^\d+$/.test(file.id) ? file.id : undefined;
void upliftsApi.downloadEvidence(upliftId, file.name, documentId).catch((error: unknown) => {
toast.error(error instanceof Error ? error.message : "Unable to download evidence.");
});
};
return (
<div
className="flex items-center gap-2"
style={{
padding: "6px 8px",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-sm)",
background: "var(--color-bg-surface)",
}}
>
<FileText
size={13}
aria-hidden
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{hasUrl && (
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate hover:underline"
style={{ fontSize: 12, color: "var(--color-text-primary)" }}
>
{formatUpliftStatusLabel(uplift.status)}
</span>
<Text as="span" variant="label">
{formatUpliftMoney(uplift.amount)}
</Text>
</div>
{Boolean(uplift.notes) && (
<Text as="p" variant="description" className="mb-2">
{uplift.notes}
</Text>
{file.name}
</a>
)}
{Boolean(uplift.requestedByName) && (
<Text as="p" variant="caption" tone="muted">
Requested by {uplift.requestedByName}
</Text>
)}
{showCancel && (
<Button
size="small"
color="inherit"
disabled={pendingAction}
onClick={onCancel}
className="mt-2"
{!hasUrl && (
<button
type="button"
onClick={downloadEvidence}
className="min-w-0 flex-1 truncate text-left hover:underline"
style={{ fontSize: 12, color: "var(--color-text-primary)" }}
>
Cancel pending
</Button>
)}
{showRevoke && (
<Button
size="small"
color="error"
disabled={pendingAction}
onClick={onRevoke}
className="mt-2"
>
Revoke
</Button>
{file.name}
</button>
)}
</div>
);
}
function UpliftDecisionCallout({ uplift }: { uplift: WorkOrderUplift }) {
const rejected = uplift.status === "rejected" && uplift.decisionNote !== "";
const revoked = uplift.status === "revoked" && uplift.decisionNote !== "";
if (!rejected && !revoked) return null;
return (
<Text
as="p"
variant="caption"
className="mt-2"
sx={{
paddingLeft: "9px",
borderLeft: rejected
? "2px solid color-mix(in srgb, var(--destructive) 45%, transparent)"
: "2px solid var(--color-border-hover)",
color: rejected ? "var(--destructive)" : "var(--muted-foreground)",
lineHeight: 1.5,
fontSize: 12,
}}
>
{uplift.decisionNote}
</Text>
);
}
function UpliftCardActions({
uplift,
readOnly,
currentUserId,
currentUserIsAdmin,
pendingAction,
onCancel,
onRevoke,
}: WorkOrderUpliftListItemProps) {
const nowMs = useUpliftClock();
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
const showRevoke =
canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) && Boolean(onRevoke);
const revokeBlocked = readOnly || pendingAction;
return (
<div className="mt-2 flex items-center justify-between gap-2">
<Text as="span" variant="caption" tone="muted">
{formatUpliftCardMeta(uplift, nowMs)}
</Text>
{showCancel && (
<Button variant="outlined" size="small" disabled={pendingAction} onClick={onCancel}>
Cancel request
</Button>
)}
{showRevoke && (
<Tooltip title={readOnly ? CLOSED_REVOKE_HINT : ""} arrow placement="top">
<span>
<Button
variant="text"
size="small"
className="gap-1"
disabled={revokeBlocked}
onClick={onRevoke}
sx={{
color: "var(--color-destructive)",
textTransform: "none",
minWidth: 0,
...(readOnly ? { opacity: 0.5 } : {}),
}}
>
<Undo2 size={12} aria-hidden /> Revoke
</Button>
</span>
</Tooltip>
)}
</div>
);
}
export function WorkOrderUpliftListItem(props: WorkOrderUpliftListItemProps) {
const { uplift } = props;
const isVoid = isVoidUpliftStatus(uplift.status);
const attachments = uplift.attachments ?? [];
const pendingBorder = uplift.status === "pending";
return (
<div
style={{
background: "var(--color-bg-surface)",
border: pendingBorder
? "1px solid color-mix(in srgb, var(--warning) 35%, transparent)"
: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "12px 16px",
}}
>
<div className="flex items-start justify-between gap-2">
<span
style={{
fontSize: 17,
fontWeight: 500,
color: isVoid ? "var(--muted-foreground)" : "var(--color-text-primary)",
textDecoration: isVoid ? "line-through" : "none",
}}
>
{formatUpliftMoney(uplift.amount)}
</span>
<UpliftStatusPill uplift={uplift} />
</div>
{uplift.notes !== "" && (
<Text
as="p"
variant="description"
className="mt-2"
sx={{ color: "var(--muted-foreground)", lineHeight: 1.5 }}
>
{uplift.notes}
</Text>
)}
{attachments.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{attachments.map((file) => (
<UpliftFiledAttachment key={file.id} upliftId={uplift.id} file={file} />
))}
</div>
)}
<UpliftDecisionCallout uplift={uplift} />
<UpliftCardActions {...props} />
</div>
);
}

View file

@ -1,104 +0,0 @@
import { CircularProgress } from "@mui/material";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { Text } from "@/components/ui/text";
import { WorkOrderUpliftCreateForm } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-create-form";
import { WorkOrderUpliftListItem } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplift-list-item";
import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-display-utils";
type WorkOrderUpliftsDialogContentProps = {
readOnly: boolean;
currentUserId: string | number | null | undefined;
currentUserIsAdmin: boolean;
readOnlyStatusLabel?: string;
isLoading: boolean;
error: Error | null;
uplifts: WorkOrderUplift[];
remainingAllowance: number;
actionPending: boolean;
createPending: boolean;
onRetry: () => void;
onCreate: (input: CreateWorkOrderUpliftInput) => void | Promise<void>;
onCancelUplift: (upliftId: string | number) => void;
onRevokeUplift: (upliftId: string | number) => void;
};
export function WorkOrderUpliftsDialogContent({
readOnly,
currentUserId,
currentUserIsAdmin,
readOnlyStatusLabel,
isLoading,
error,
uplifts,
remainingAllowance,
actionPending,
createPending,
onRetry,
onCreate,
onCancelUplift,
onRevokeUplift,
}: WorkOrderUpliftsDialogContentProps) {
const hasOpenRequest = hasOpenWorkOrderUplift(uplifts);
return (
<>
{readOnly && (
<Text as="p" variant="description" tone="muted">
This work order is {readOnlyStatusLabel?.toLowerCase()}. Uplifts are read-only.
</Text>
)}
{Boolean(error) && (
<QueryErrorPanel
compact
message={error instanceof Error ? error.message : "Failed to load uplifts."}
onRetry={onRetry}
/>
)}
{isLoading && (
<div className="flex justify-center py-6">
<CircularProgress size={24} />
</div>
)}
{!isLoading && !error && uplifts.length === 0 && (
<Text as="p" variant="description" tone="muted">
No uplift requests yet.
</Text>
)}
{!isLoading &&
uplifts.map((uplift) => (
<WorkOrderUpliftListItem
key={String(uplift.id)}
uplift={uplift}
readOnly={readOnly}
currentUserId={currentUserId}
currentUserIsAdmin={currentUserIsAdmin}
pendingAction={actionPending}
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}
/>
))}
{!readOnly && !isLoading && hasOpenRequest && (
<Text as="p" variant="description" tone="muted">
Cancel the pending request to file a new one.
</Text>
)}
{!readOnly && !isLoading && !hasOpenRequest && (
<WorkOrderUpliftCreateForm
pending={createPending}
remainingAllowance={remainingAllowance}
onSubmit={onCreate}
/>
)}
</>
);
}

View file

@ -1,124 +0,0 @@
import { useMemo, useState } from "react";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { RevokeUpliftDialog } from "@/app/(protected)/workorders/_components/uplifts/revoke-uplift-dialog";
import { WorkOrderUpliftsDialogContent } from "@/app/(protected)/workorders/_components/uplifts/work-order-uplifts-dialog-content";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderUpliftStatus } from "@/domain/work-orders/types/work-order-uplift";
import {
useCancelWorkOrderUplift,
useCreateWorkOrderUplift,
useRevokeWorkOrderUplift,
useWorkOrderUplifts,
} from "@/domain/work-orders/use-cases/use-work-order-uplifts";
import {
getRemainingUpliftAllowance,
isWorkOrderUpliftsReadOnly,
upliftRevokeRequiresReason,
} from "@/domain/work-orders/utils/uplift-display-utils";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
type RevokeTarget = {
id: string | number;
status: WorkOrderUpliftStatus;
};
type WorkOrderUpliftsDialogProps = {
row: WorkOrderTableRow | null;
open: boolean;
onClose: () => void;
};
export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsDialogProps) {
const { user } = useAuthContext();
const workOrderId = row?.id ?? null;
const readOnly = row ? isWorkOrderUpliftsReadOnly(row.status) : true;
const {
data: uplifts = [],
isLoading,
error,
refetch,
} = useWorkOrderUplifts(open ? workOrderId : null);
const createUplift = useCreateWorkOrderUplift(workOrderId ?? "");
const cancelUplift = useCancelWorkOrderUplift(workOrderId ?? "");
const revokeUplift = useRevokeWorkOrderUplift(workOrderId ?? "");
const [revokeTarget, setRevokeTarget] = useState<RevokeTarget | null>(null);
const actionPending = createUplift.isPending || cancelUplift.isPending || revokeUplift.isPending;
const revokeDialogCopy = useMemo(() => {
if (revokeTarget?.status === "approved") {
return {
title: "Revoke approved uplift?",
description:
"Admin revoke requires a reason. This will mark the uplift as revoked and store your note with the decision.",
};
}
return {
title: "Revoke auto-approved uplift?",
description:
"This will mark the uplift as revoked. Optional reason is stored with the decision.",
};
}, [revokeTarget?.status]);
const handleRevokeUplift = (upliftId: string | number) => {
const uplift = uplifts.find((item) => String(item.id) === String(upliftId));
if (!uplift) {
return;
}
setRevokeTarget({ id: upliftId, status: uplift.status });
};
const remainingAllowance = getRemainingUpliftAllowance(row?.type ?? "", uplifts);
return (
<>
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Uplifts — WO {row?.woNumber ?? ""}</DialogTitle>
<DialogContent className="flex flex-col gap-3">
<WorkOrderUpliftsDialogContent
readOnly={readOnly}
currentUserId={user?.id}
currentUserIsAdmin={isAdminUser(user?.userRoles)}
readOnlyStatusLabel={row?.status}
isLoading={isLoading}
error={error}
uplifts={uplifts}
remainingAllowance={remainingAllowance}
actionPending={actionPending}
createPending={createUplift.isPending}
onRetry={() => void refetch()}
onCreate={async (input) => {
await createUplift.mutateAsync(input);
}}
onCancelUplift={(upliftId) => cancelUplift.mutate(upliftId)}
onRevokeUplift={handleRevokeUplift}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
<RevokeUpliftDialog
open={revokeTarget != null}
pending={revokeUplift.isPending}
requireReason={
revokeTarget != null ? upliftRevokeRequiresReason(revokeTarget.status) : false
}
title={revokeDialogCopy.title}
description={revokeDialogCopy.description}
onClose={() => setRevokeTarget(null)}
onConfirm={(reason) => {
if (revokeTarget == null) {
return;
}
revokeUplift.mutate(
{ upliftId: revokeTarget.id, reason },
{ onSuccess: () => setRevokeTarget(null) },
);
}}
/>
</>
);
}

View file

@ -65,11 +65,8 @@ export function useNewWoWizardController({
};
const createWorkOrder = useCreateWorkOrderFromWizard((createdDraft) => {
// Close the dialog first: the work-order create's isPending has already dropped, so
// holding the dialog open through the notes save would re-enable Create and unlock
// pointer events, and a second click would create a second work order. The save runs
// independently of the dialog — fetchCurrentBaseline covers a missing baseline and the
// note-save failure toast is surfaced from the mutation itself, not the dialog.
// hazard: Create's isPending has already dropped, so an open dialog re-enables Create and a
// second click creates another work order. Close first; the notes save continues on its own.
onOpenChange(false);
void saveCompanyNotes(createdDraft);
});

View file

@ -29,7 +29,7 @@ export function buildCreatedTechniciansPatch(
): Partial<WorkOrderWizardDraft> {
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
const [only] = created;
// Prototype parity: a single new technician is assigned when none was picked.
// why: Prototype parity: a single new technician is assigned when none was picked.
if (created.length === 1 && only != null && draft.vendorId === "") {
patch.vendorId = String(only.id);
patch.vendorName = company;
@ -57,9 +57,9 @@ export function useWizardCreateWithTechnicians({
createMutation.mutate(draft);
return;
}
// Mirror the vendor roster schema so an unparseable phone is caught here with a
// clear message instead of failing the roster PATCH and blocking Create with the
// raw backend sentence. A blank phone stays valid, matching the modal path.
// why: Mirror the vendor roster schema so an unparseable phone is caught here with a clear message
// instead of failing the roster PATCH and blocking Create with the raw backend sentence. A blank
// phone stays valid, matching the modal path.
if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) {
toast.error("Enter a 10-digit phone number for each new technician.");
return;
@ -80,7 +80,7 @@ export function useWizardCreateWithTechnicians({
})),
});
const patch = buildCreatedTechniciansPatch(draft, company, pending, created);
// Clear the staged rows first so a failed WO create cannot re-create them on retry.
// why: Clear the staged rows first so a failed WO create cannot re-create them on retry.
patchDraft(patch);
createMutation.mutate({ ...draft, ...patch });
} catch (error) {

View file

@ -40,7 +40,7 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set
useEffect(() => {
if (!vendorId) {
// Cleared technician: the next pick re-seeds even for the same company.
// why: Cleared technician: the next pick re-seeds even for the same company.
if (baseline) setBaseline(null);
return;
}
@ -64,8 +64,8 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set
if (current.vendorId !== vendorId) {
return current;
}
// Seed only empty content or content still holding the previous company's unedited
// notes; anything else is the dispatcher's typing and must never be overwritten.
// why: Seed only empty content or content still holding the previous company's unedited notes; anything
// else is the dispatcher's typing and must never be overwritten.
if (current.vendorNotes !== "" && current.vendorNotes !== baseline?.notes) {
return current;
}
@ -112,7 +112,7 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set
try {
await saveNotes.mutateAsync(update);
} catch {
// The mutation already surfaces the note-save failure toast.
// why: The mutation already surfaces the note-save failure toast.
}
}

View file

@ -274,9 +274,9 @@ export function WizardStepTypeSchedule({
onPatch({
type: t,
severity: nextSeverityForType(draft.type, t, draft.severity),
// A registry service is scoped to its work-order type, so a service picked
// under the previous type fails backend ResolveServiceAsync (SH-187/#131).
// Clear the service (and its vendor, filtered by the service) on a type change.
// why: A registry service is scoped to its work-order type, so a service picked under the previous type
// fails backend ResolveServiceAsync (SH-187/#131). Clear the service (and its vendor, filtered by
// the service) on a type change.
...(typeChanged
? {
pm: "",

View file

@ -162,8 +162,8 @@ export function createCompDocDialogActions({
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
}
// Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes
// and bumps the work-order row version in one transaction. Reflect that in the UI and refresh
// why: Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes and
// bumps the work-order row version in one transaction. Reflect that in the UI and refresh
// authoritative state, but do not re-run the Generate persist — that patch would carry the
// pre-upload row version and 409, surfacing an error right after a successful upload.
function markSignedPdfUploaded() {

View file

@ -8,8 +8,8 @@ export function useDashboardDrilldownFilters(
): boolean {
const [searchParams, setSearchParams] = useSearchParams();
const appliedRef = useRef(false);
// Hold onApply in a ref so a caller passing a fresh closure each render does not
// re-run the apply effect; it should fire only when the URL search params change.
// why: Hold onApply in a ref so a caller passing a fresh closure each render does not re-run the apply
// effect; it should fire only when the URL search params change.
const onApplyRef = useRef(onApply);
const [fromDashboard, setFromDashboard] = useState(false);
@ -20,7 +20,7 @@ export function useDashboardDrilldownFilters(
useEffect(() => {
const drilldown = parseDashboardDrilldownParams(searchParams);
if (!drilldown) {
// The applied params were cleared; a later drilldown link on this mounted page applies too.
// why: The applied params were cleared; a later drilldown link on this mounted page applies too.
appliedRef.current = false;
return;
}

View file

@ -33,7 +33,7 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
() =>
buildResolvedBoardSearchParams({
advApplied: filters.advApplied,
// Advanced Filters are global by design; the week-scoped keyword box has no effect.
// why: Advanced Filters are global by design; the week-scoped keyword box has no effect.
search: "",
page: filters.page,
pageSize: filters.pageSize,

View file

@ -24,8 +24,8 @@ function useSlideOverOpenState() {
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
const [slideOverHighlight, setSlideOverHighlight] = useState<CommentHighlight | null>(null);
// Bumps on every open() so the slide-over re-applies tab/highlight even when a second
// notification targets the work order already on screen (same row id).
// why: Bumps on every open() so the slide-over re-applies tab/highlight even when a second notification
// targets the work order already on screen (same row id).
const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0);
const handleOpenSlideOver = (
@ -73,7 +73,6 @@ export function useWorkOrdersListPage() {
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [upliftsRow, setUpliftsRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
@ -117,10 +116,9 @@ export function useWorkOrdersListPage() {
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
const activeUpliftsRow = useMemo(() => {
if (!upliftsRow) return null;
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
}, [upliftsRow, tableData.rows]);
const handleOpenUplifts = (row: WorkOrderTableRow) => {
handleOpenSlideOver(row, false, "uplifts");
};
const handleCloseSlideOver = () => {
closeSlideOverState();
@ -160,11 +158,11 @@ export function useWorkOrdersListPage() {
return;
}
try {
// Generation is the one automatic completion — no confirmation dialog.
// why: Generation is the one automatic completion — no confirmation dialog.
await patchFieldAsync(row.id, { status: "Completed" }, saved.row);
} catch {
// The document is saved; the table mutation already surfaced the status error and
// refetched, so the WO stays in its prior status for a manual retry.
// why: The document is saved; the table mutation already surfaced the status error and refetched, so
// the WO stays in its prior status for a manual retry.
}
};
@ -183,7 +181,6 @@ export function useWorkOrdersListPage() {
setDocRow,
mediaRow,
setMediaRow,
setUpliftsRow,
completionDocs,
filters,
tableData,
@ -199,8 +196,8 @@ export function useWorkOrdersListPage() {
activeSlideOverRow,
activeDocRow,
activeMediaRow,
activeUpliftsRow,
handleOpenSlideOver,
handleOpenUplifts,
handleCloseSlideOver,
handleConfirmCancel,
handleConfirmComplete,

View file

@ -32,7 +32,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
});
const { displayRows, ...localUi } = local;
// SH-338: the server owns rows/totals for advanced search (incl. Unscheduled).
// why: SH-338: the server owns rows/totals for advanced search (incl. Unscheduled).
const advResults = useMemo(
() => (advActive ? displayRows : EMPTY_WEEKLY_ITEMS),
[advActive, displayRows],

View file

@ -30,7 +30,7 @@ export default function WorkOrdersListPage() {
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
useWorkOrderDeepLink({
onOpenSlideOver: page.handleOpenSlideOver,
onOpenUplifts: page.setUpliftsRow,
onOpenUplifts: page.handleOpenUplifts,
});
return (
@ -116,7 +116,7 @@ export default function WorkOrdersListPage() {
onRequestComplete={page.setConfirmComplete}
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
onOpenMedia={page.setMediaRow}
onOpenUplifts={page.setUpliftsRow}
onOpenUplifts={page.handleOpenUplifts}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={page.patchField}

View file

@ -47,7 +47,6 @@ export function InviteWizard({ token, details, onInviteInvalid }: InviteWizardPr
onSuccess: () => setStep(2),
onError: (error) => {
if (endedInvite(error)) return;
// A code sent moments ago is still valid, so the member can go on and enter it.
if (inviteErrorCode(error) === "resend_too_soon") {
setStep(2);
return;

View file

@ -33,9 +33,6 @@ function InviteBody({ token }: { token: string }) {
);
}
// Only the server's own `invalid_invite` answer means the link is dead. A request that never
// got that answer (offline, timeout, 5xx) says nothing about the token, so offer a retry
// instead of sending the member to their admin for a new invite.
if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") {
return (
<Box>

View file

@ -26,7 +26,6 @@ function statusClassName(status: string): string {
}
function availableActions(request: VendorPortalUpliftRequest, locked: boolean, canAct: boolean) {
// Requests raised from the work order are visible but read-only to the vendor.
const actionable = !locked && canAct && request.raisedByVendor !== false;
return {
canWithdraw: actionable && WITHDRAWABLE_STATUSES.has(request.status),
@ -34,7 +33,6 @@ function availableActions(request: VendorPortalUpliftRequest, locked: boolean, c
};
}
// A vendor request stores the requested new NTE total; a work-order request stores the increase.
function requestedTotal(request: VendorPortalUpliftRequest): number {
const requested = Number(request.requestedNTE);
return request.raisedByVendor === false ? Number(request.currentNTE ?? 0) + requested : requested;

View file

@ -50,7 +50,6 @@ export function UpliftRequestForm({
const submit = (event: React.FormEvent) => {
event.preventDefault();
// The picked file is not in state until its check settles; submitting now would validate the old one.
if (checkingEvidence) return;
const amount = Number.parseFloat(requested);
const validationError = validateUpliftAmount(requested, currentNte, reason, {
@ -69,7 +68,6 @@ export function UpliftRequestForm({
const selection = ++latestSelection.current;
setCheckingEvidence(Boolean(file));
const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
// A newer pick may have settled while this one's video metadata was still loading.
if (selection !== latestSelection.current) return;
setFormError(validationMessage ?? "");
setEvidenceFile(file);

View file

@ -97,7 +97,6 @@ export function VendorPortalDocuments({
const [replacementId, setReplacementId] = useState<number>();
const [progress, setProgress] = useState<number>();
const [validationError, setValidationError] = useState<string>();
// Bumped on every new pick so a slower validation of an earlier file cannot upload it.
const selectionRef = useRef(0);
const uploadMutation = useMutation({

View file

@ -50,16 +50,14 @@ export function AddressAutocompleteField({
freeSolo
disabled={disabled}
options={suggestAddresses(value)}
// The options are already derived from the input; letting MUI filter them
// again would drop every row whose label does not literally contain the
// typed text.
// why: The options are already derived from the input; letting MUI filter them again would drop every
// row whose label does not literally contain the typed text.
filterOptions={(options) => options}
inputValue={value}
onInputChange={(_event, next, reason) => {
// Genuine typing and the clear control update the street. MUI also
// fires this callback with reason "reset" after a selection, carrying
// the option's full "<street>, <city>, <state>" label — forwarding
// that would overwrite the just-picked street with the label.
// why: Genuine typing and the clear control update the street. MUI also fires this callback with reason
// "reset" after a selection, carrying the option's full "<street>, <city>, <state>" label —
// forwarding that would overwrite the just-picked street with the label.
if (reason !== "input" && reason !== "clear") return;
onInputChange(next);
}}
@ -81,8 +79,6 @@ export function AddressAutocompleteField({
helperText={helperText}
fullWidth
required={requiredMarker}
// Required is set on the input itself; MUI's `required` prop only
// adds the label asterisk, which Vendors must not render.
slotProps={{
...params.slotProps,
htmlInput: { ...params.slotProps?.htmlInput, required },

View file

@ -47,8 +47,8 @@ export function AddressMapPreview({
height = 160,
placeholderWhenIncomplete = false,
}: AddressMapPreviewProps) {
// Structured input is held to the completeness rule; a pre-composed line is
// saved data and only has to be non-empty.
// why: Structured input is held to the completeness rule; a pre-composed line is saved data and only
// has to be non-empty.
const query = address ? buildAddressQuery(address) : (rawQuery ?? "").trim();
const complete = address ? hasCompleteAddress(address) : Boolean(query);

View file

@ -7,7 +7,6 @@ type NotificationFeedActionsProps = {
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
const hasUnread = center.unreadIds.size > 0;
// Rows that need acknowledging are never cleared, so they alone do not enable "Clear all".
const hasItems = center.sections.some((section) => section.items.some(isDismissable));
return (

View file

@ -37,7 +37,8 @@ const ICON_PROPS = {
style: { color: "var(--color-text-muted)", marginTop: 2 },
} as const;
// Recent activity mixes comments, assignments and uplift decisions; where the row leads tells them apart.
// why: Recent activity mixes comments, assignments and uplift decisions; where the row leads tells them
// apart.
function ItemIcon({ item }: { item: NotificationItem }) {
if (item.reason === "activity" && item.target.tab === "uplifts") {
return <CircleDollarSign {...ICON_PROPS} />;

View file

@ -78,10 +78,6 @@ export const navGroups: NavGroup[] = [
// },
];
// Approvals navigation is gated on the same server-derived authority the
// uplift approve/reject API enforces (UpliftService.CanApprove tier roles,
// exposed via GET uplifts/can-approve), so the sidebar never shows the queue
// to a user the backend would refuse.
const approvalsNavGroup: NavGroup = {
title: "APPROVALS",
items: [{ label: "Uplift Approvals", icon: BadgeDollarSign, path: "/uplifts" }],

View file

@ -40,12 +40,10 @@ export async function changePassword(data: ChangePasswordPayload): Promise<unkno
return apiPost(API_PATHS.authentication.changePassword, data);
}
// Email and code travel in the JSON body so they stay out of URLs, traces and proxy logs.
export async function forgotPassword(email: string): Promise<unknown> {
return apiPost(API_PATHS.authentication.forgetPassword, { email });
}
// The server checks a code only against the email it was issued to.
export async function verifyResetCode(email: string, code: string): Promise<unknown> {
return apiPost(API_PATHS.authentication.verificationCode, { email, code });
}

View file

@ -13,10 +13,8 @@ export interface CompletionTemplateLegacyFields {
export interface CompletionTemplate {
id: number;
/** The Document Title. */
name: string;
extraSafetyNote: string | null;
/** In print order. */
procedures: CompletionTemplateProcedure[];
createdAt: string;
/** `updatedAt ?? createdAt`: what the table shows as Last Updated. */

View file

@ -30,7 +30,6 @@ export function isTitleRequiredError(error: Error): boolean {
function invalidateTemplates(queryClient: QueryClient) {
void queryClient.invalidateQueries({ queryKey: queryKeys.completionTemplates.all });
// The Services registry lists template names and clears links to a deleted template.
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
}

View file

@ -101,12 +101,9 @@ export function scheduledTomorrowDrilldownSearch(
}
export function avetaPendingDrilldownSearch(today: string = businessTodayIso()): URLSearchParams {
// The Aveta Pending count (backend#138 / SH-294) is: AvetaRequired, scheduled
// today or tomorrow, open, and missing its Aveta-category document. The board
// has no missing-Aveta-document filter, so the closest reachable set is the
// Aveta-required work in the two-day open window. `docs=No` is deliberately
// NOT sent: it filters on completion-document status, a different document
// from the Aveta clearance one, so it would narrow to the wrong rows.
// why: Aveta Pending (SH-294) is Aveta-required, open, today or tomorrow, missing that document.
// The board cannot filter a missing Aveta document, so the link is that two-day open set.
// hazard: do not send docs=No — that is completion-doc status, a different document.
const search = new URLSearchParams();
search.set("dateFrom", today);
search.set("dateTo", addDaysIso(today, 1));

View file

@ -236,7 +236,6 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
state: item.state ?? "",
zipCode: item.zipCode ?? "",
phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "",
// A caller without a contacts list (e.g. address-only sync) keeps the stored legacy name.
contact: mainContact ? mainContact.name : readString(item, "contact").trim(),
contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "",
accountId: item.accountId ?? "",

View file

@ -10,9 +10,7 @@ export interface LocationContact {
export interface LocationListItem {
id: string | number;
name: string;
/** Client (account) display name. */
clientName: string;
/** Site Phone, independent of the contacts. */
sitePhone: string;
address: string;
city: string;

View file

@ -20,7 +20,6 @@ export function NotificationSessionProvider({ children }: { children: ReactNode
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
() => new Set(),
);
// A ref, not state: claiming must be synchronous so a re-run effect can never toast twice.
const toastedSlaIds = useRef(new Set<number>());
const dismiss = useCallback((ids: string[]) => {

View file

@ -33,7 +33,7 @@ const itemSchema = z.object({
id: z.string(),
reason: z.string(),
severity: severitySchema,
// "acknowledge" rows (a missed response deadline) never take the normal dismiss.
// why: "acknowledge" rows (a missed response deadline) never take the normal dismiss.
rowType: z.enum(["dismissable", "acknowledge"]),
title: z.string(),
count: z.number(),
@ -63,7 +63,6 @@ const slaAtRiskSchema = z.object({
export const notificationFeedSchema = z.object({
generatedAt: z.string(),
sections: z.array(sectionSchema),
// Defaulted so a feed from a server without SLA alerts still parses.
slaAtRisk: z.array(slaAtRiskSchema).default([]),
});

View file

@ -10,7 +10,7 @@ export function useNotificationFeed() {
queryKey: queryKeys.notifications.feed(),
queryFn: notificationsApi.feed,
refetchInterval: NOTIFICATION_FEED_REFRESH_MS,
// The feed shows its own error state; a toast on every 30s poll would repeat the same failure.
// why: The feed shows its own error state; a toast on every 30s poll would repeat the same failure.
meta: { suppressErrorToast: true },
});
}

View file

@ -20,8 +20,6 @@ const SERVICE_WORK_ORDER_TYPE_API = {
Reactive: 6,
} as const;
// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the
// client never confirmed, so it lists the PM catalog.
const TYPE_TO_API: Record<WOType, number> = {
PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive,

View file

@ -25,9 +25,9 @@ export const EMPTY_SERVICE_FORM: ServiceForm = {
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
};
// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not
// the legacy DropdownOptions seed. A trade with no explicit default falls back
// to "wrench" where this map is read.
// why: Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not the legacy
// DropdownOptions seed. A trade with no explicit default falls back to "wrench" where this map is
// read.
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
HVAC: "droplets",
"Plumbing & Water Systems": "droplets",
@ -87,9 +87,9 @@ function formFromService(service: Service): ServiceForm {
};
}
// Owns the add/edit form lifecycle so the top-level controller stays within the
// changed-file maintainability budget. Mutations are passed in so `isSaving` and
// `closeEditor` share the exact instances the controller reports on.
// why: Owns the add/edit form lifecycle so the top-level controller stays within the changed-file
// maintainability budget. Mutations are passed in so `isSaving` and `closeEditor` share the exact
// instances the controller reports on.
function useServiceEditor(
canonicalTrades: string[],
canDeactivate: boolean,
@ -102,9 +102,9 @@ function useServiceEditor(
const [iconTouched, setIconTouched] = useState(false);
const [formError, setFormError] = useState("");
// Keep the trade stored on the service being edited selectable even if it was
// later deactivated in the canonical Trade catalog, so editing never silently
// blanks the field and forces an unrelated trade change.
// why: Keep the trade stored on the service being edited selectable even if it was later deactivated in
// the canonical Trade catalog, so editing never silently blanks the field and forces an unrelated
// trade change.
const trades = useMemo(() => {
if (form.trade && !canonicalTrades.includes(form.trade)) {
return [form.trade, ...canonicalTrades];
@ -208,11 +208,10 @@ export function useServicesRegistryController() {
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
const servicesQuery = useServices();
// The backend rejects any Trade outside the canonical TradeCatalog (400
// TradeInvalid), so the picker is sourced from the server-owned canonical
// Trade reference exposed on Vendor/facets — the same list the vendor roster
// form uses — never the editable DropdownOptions "Trade" category, whose
// legacy seed values ("Plumbing", "General", ...) would fail on save.
// why: The backend rejects any Trade outside the canonical TradeCatalog (400 TradeInvalid), so the
// picker is sourced from the server-owned canonical Trade reference exposed on Vendor/facets — the
// same list the vendor roster form uses — never the editable DropdownOptions "Trade" category,
// whose legacy seed values ("Plumbing", "General", ...) would fail on save.
const facetsQuery = useVendorFacets();
const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]);
const { data: templates = [] } = useCompletionDocTemplates();

View file

@ -1,7 +1,4 @@
/**
* Anonymous invite registration. The invite token travels only in request bodies,
* never in a URL path or query, and the server scopes every call to that token.
*/
// invariant: the invite token travels only in request bodies, never in a URL path or query.
import { isHTTPError } from "ky";
import { apiPost, apiPostNoContent } from "@/api/api";
@ -34,7 +31,6 @@ export const teamMemberInviteApi = {
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.complete, {
token: input.token,
password: input.password,
// An empty value clears the phone the admin entered.
phone: input.phone.trim(),
});
const user = handleApiResponse<AuthUser>(data);

View file

@ -88,7 +88,6 @@ export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[
export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail {
const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const listItem = mapTeamMemberListItem(record);
// Present only on an update that re-sent a pending member's invite.
const inviteEmailSent = record.inviteEmailSent ?? record.InviteEmailSent;
return {
...listItem,

View file

@ -105,8 +105,9 @@ export const upliftsApi = {
downloadEvidence: async (
id: string | number,
fallbackFileName = "uplift-evidence",
documentId?: string | number,
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence", documentId);
const blob = await response.blob();
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
},
@ -120,8 +121,9 @@ export const upliftsApi = {
id: string | number,
tab: Window | null,
fallbackFileName = "uplift-evidence",
documentId?: string | number,
): Promise<void> => {
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
const response = await fetchEvidence(id, "upliftsApi.openEvidence", documentId);
const blob = await response.blob();
const type = blob.type.split(";")[0].trim().toLowerCase();
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
@ -145,9 +147,14 @@ const INLINE_EVIDENCE_TYPES = new Set([
const EVIDENCE_URL_TTL_MS = 60_000;
async function fetchEvidence(id: string | number, operation: string): Promise<Response> {
async function fetchEvidence(
id: string | number,
operation: string,
documentId?: string | number,
): Promise<Response> {
const options = documentId == null ? undefined : { searchParams: { documentId } };
try {
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
return await apiRequestRaw("get", evidenceUrl(id), options, operation);
} catch (error) {
if (error instanceof HTTPError) {
throw evidenceHttpError(error.response.status);

View file

@ -26,8 +26,8 @@ function invalidateUpliftQueries(
dispatchId?: string | number,
) {
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all });
// Queue decisions update the same uplift records rendered by Work Order views.
// Invalidate that shared cache even when the queue has no dispatch context.
// why: Queue decisions update the same uplift records rendered by Work Order views. Invalidate that
// shared cache even when the queue has no dispatch context.
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
if (dispatchId !== undefined && dispatchId !== "") {
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.forDispatch(dispatchId) });

View file

@ -10,8 +10,6 @@ export function useUpliftsQueue(
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
queryFn: () => upliftsApi.list(params),
placeholderData: keepPreviousData,
// The Uplift Approvals page reports a failed queue inline, so the global toast
// would announce the same failure twice.
meta: { suppressErrorToast: true },
});
}

View file

@ -19,8 +19,6 @@ export const VENDOR_UPLOAD_ACCEPT =
type VendorUploadKind = "document" | "photo" | "video";
// Mirrors the server allowlist: an allowlisted browser type wins, otherwise the
// extension decides (mobile browsers often send an empty or octet-stream type).
const KIND_BY_MIME: Record<string, VendorUploadKind> = {
"application/pdf": "document",
"image/jpeg": "photo",
@ -114,7 +112,6 @@ export async function validateVendorDocument(
if (file.size > MAX_VIDEO_BYTES) {
return "Videos must be 100 MB or smaller.";
}
// Pre-check; the server enforces it too. Unreadable metadata never blocks.
const duration = await probeVideoDurationSeconds(file);
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`;

View file

@ -77,8 +77,8 @@ export const vendorsApi = {
};
},
// SH-281: grouped company rows come from the dedicated directory endpoint;
// Vendor/GetVendorList stays on the legacy technician-level contract.
// why: SH-281: grouped company rows come from the dedicated directory endpoint; Vendor/GetVendorList
// stays on the legacy technician-level contract.
getDirectoryList: async (params: VendorsListParams = {}): Promise<VendorsListResult> => {
const data = await apiGet<unknown>(API_PATHS.vendor.getDirectoryList, {
searchParams: buildListParams(params),

View file

@ -131,15 +131,15 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
// The stored Maps URL is deprecated: reads keep it for legacy records, but
// roster writes must not carry it back to the backend.
// why: The stored Maps URL is deprecated: reads keep it for legacy records, but roster writes must not
// carry it back to the backend.
notes: readString(item, "notes", "Notes"),
technicians: Array.isArray(techniciansRaw)
? techniciansRaw.map(mapRosterTechnicianToBackend)
: [],
};
// The form always round-trips its Area (id or null = Unassigned). Callers
// without an Area value omit it, which the API treats as "leave unchanged".
// why: The form always round-trips its Area (id or null = Unassigned). Callers without an Area value
// omit it, which the API treats as "leave unchanged".
if ("areaId" in item || "AreaId" in item) {
payload.areaId = toBackendAreaId(item.areaId ?? item.AreaId);
}
@ -173,7 +173,7 @@ export function mapVendorRosterAdditivePatchToBackend(
const companyFields: Record<string, unknown> = { ...patch.companyFields };
if (companyFields.companyPhone !== undefined)
companyFields.companyPhone = toCanonicalPhone(companyFields.companyPhone);
// An edited Area is sent as an id or an explicit null (Unassigned).
// why: An edited Area is sent as an id or an explicit null (Unassigned).
if (companyFields.areaId !== undefined)
companyFields.areaId = toBackendAreaId(companyFields.areaId);
payload.companyFields = companyFields;

View file

@ -42,11 +42,10 @@ const baseCompanyFields = {
companyContact: z.never().optional(),
companyPhone: northAmericanPhone,
email: optionalEmail,
// SH-271: the single combined Address line became three structured parts,
// all required, and the stored Google Maps URL was retired from the form.
// `googleMapsUrl` stays on the read models for legacy records but is not a
// form value: loading it would validate and re-emit a deprecated field the
// user can no longer see or edit.
// why: SH-271: the single combined Address line became three structured parts, all required, and the
// stored Google Maps URL was retired from the form. `googleMapsUrl` stays on the read models for
// legacy records but is not a form value: loading it would validate and re-emit a deprecated field
// the user can no longer see or edit.
address: z.string().trim().min(1, "Street Address is required"),
city: z.string().trim().min(1, "City is required"),
state: z.string().trim().min(1, "State is required"),
@ -54,7 +53,7 @@ const baseCompanyFields = {
notes: z
.string()
.max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`),
// Optional stable Area id; "" means Unassigned. Admin-only to change.
// why: Optional stable Area id; "" means Unassigned. Admin-only to change.
areaId: z.string(),
technicians: z.array(rosterTechnicianSchema),
};

View file

@ -26,9 +26,8 @@ export interface SaveVendorCompanyRosterContext {
conflict?: unknown;
}
// `googleMapsUrl` is deliberately absent: it is a deprecated read-only field,
// so neither the additive companyFields patch nor the status-only diff may
// ever transmit it.
// why: `googleMapsUrl` is deliberately absent: it is a deprecated read-only field, so neither the
// additive companyFields patch nor the status-only diff may ever transmit it.
const COMPANY_FIELDS = [
"name",
"companyPhone",

View file

@ -216,10 +216,10 @@ export const workOrderBoardDocumentsApi = {
workOrderId: string | number,
mediaId: string | number,
): Promise<Blob> => {
// No `credentials: "include"`: this endpoint authenticates with the bearer token the ky
// beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the
// API's `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document
// would never open.
// why: No `credentials: "include"`: this endpoint authenticates with the bearer token the ky
// beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the API's
// `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document would never
// open.
const response = await apiRequestRaw(
"get",
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),

View file

@ -1,5 +1,5 @@
import { API_PATHS } from "@/api/api-paths";
import { apiGet, apiPost } from "@/api/api";
import { apiGet, apiPost, apiPostForm } from "@/api/api";
import { handleApiResponse } from "@/api/handle-api-response";
import {
mapWorkOrderUplift,
@ -19,6 +19,22 @@ function extractUpliftItems(data: unknown): unknown[] {
return Array.isArray(items) ? items : [];
}
export type UpliftEvidenceSnapshot = {
id: number;
scanStatus: string;
originalFileName: string;
};
function readEvidenceSnapshot(data: unknown): UpliftEvidenceSnapshot {
const record = handleApiResponse<Record<string, unknown>>(data);
const id = Number(record.id ?? record.Id);
return {
id,
scanStatus: String(record.scanStatus ?? record.ScanStatus ?? ""),
originalFileName: String(record.originalFileName ?? record.OriginalFileName ?? ""),
};
}
export const workOrderUpliftsApi = {
list: async (workOrderId: string | number): Promise<WorkOrderUplift[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.uplifts(workOrderId));
@ -30,13 +46,45 @@ export const workOrderUpliftsApi = {
workOrderId: string | number,
input: CreateWorkOrderUpliftInput,
): Promise<WorkOrderUplift> => {
const data = await apiPost<unknown>(API_PATHS.workOrder.uplifts(workOrderId), {
const body: {
amount: number;
notes: string;
evidenceDocumentId?: number;
evidenceDocumentIds?: number[];
} = {
amount: input.amount,
notes: input.notes,
});
};
if (input.evidenceDocumentId != null) {
body.evidenceDocumentId = input.evidenceDocumentId;
}
if (input.evidenceDocumentIds != null && input.evidenceDocumentIds.length > 0) {
body.evidenceDocumentIds = input.evidenceDocumentIds;
}
const data = await apiPost<unknown>(API_PATHS.workOrder.uplifts(workOrderId), body);
return mapWorkOrderUplift(handleApiResponse(data));
},
uploadEvidence: async (
workOrderId: string | number,
file: File,
): Promise<UpliftEvidenceSnapshot> => {
const body = new FormData();
body.append("file", file);
const data = await apiPostForm<unknown>(API_PATHS.workOrder.upliftEvidence(workOrderId), body);
return readEvidenceSnapshot(data);
},
getEvidenceStatus: async (
workOrderId: string | number,
documentId: number,
): Promise<UpliftEvidenceSnapshot> => {
const data = await apiGet<unknown>(
API_PATHS.workOrder.upliftEvidenceStatus(workOrderId, documentId),
);
return readEvidenceSnapshot(data);
},
cancel: async (
workOrderId: string | number,
upliftId: string | number,

View file

@ -227,7 +227,6 @@ export const workOrdersApi = {
"Unable to verify the work order number: search result set exceeded the safety page limit.",
);
}
// Every week plus undated rows: a WO# must be unique regardless of schedule.
const result = await workOrdersApi.searchBoard({
search: normalized,
datePreset: "Custom",

View file

@ -77,11 +77,8 @@ function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] {
];
}
/**
* Expands a UI table patch into ordered board ops (one request each).
* Schedule/week-only fields are emitted first in a stable order; the composite
* POC op follows, then scalar fields.
*/
// why: Schedule and week fields must go out first, in a stable order, then the composite POC op,
// then scalar fields — each as its own board request.
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
if (isLocalOnlyTablePatch(patch)) {
return [];

View file

@ -1,6 +1,7 @@
import type {
UpliftSummary,
WorkOrderUplift,
WorkOrderUpliftAttachment,
WorkOrderUpliftStatus,
} from "@/domain/work-orders/types/work-order-uplift";
import { WORK_ORDER_UPLIFT_STATUSES } from "@/domain/work-orders/types/work-order-uplift";
@ -123,6 +124,53 @@ export function mapUpliftSummary(raw: unknown): UpliftSummary {
return summary;
}
function mapUpliftAttachment(raw: unknown, index: number): WorkOrderUpliftAttachment | null {
const record = asRecord(raw);
const name = readString(record, "name", "Name", "fileName", "FileName");
if (name === "") return null;
const idValue = record.id ?? record.Id;
const id =
typeof idValue === "string" || typeof idValue === "number"
? String(idValue)
: `${name}-${index}`;
return {
id,
name,
url: readString(record, "url", "Url", "fileUrl", "FileUrl"),
};
}
function mapUpliftAttachments(raw: unknown): WorkOrderUpliftAttachment[] {
if (!Array.isArray(raw)) return [];
return raw.flatMap((item, index) => {
const attachment = mapUpliftAttachment(item, index);
return attachment == null ? [] : [attachment];
});
}
function mapEvidenceAttachment(item: Record<string, unknown>): WorkOrderUpliftAttachment[] {
const listed = mapUpliftAttachments(
item.attachments ?? item.Attachments ?? item.documents ?? item.Documents,
);
if (listed.length > 0) return listed;
const evidenceId = readOptionalId(item, "evidenceDocumentId", "EvidenceDocumentId");
const name = readString(
item,
"evidenceFileName",
"EvidenceFileName",
"originalFileName",
"OriginalFileName",
);
if (evidenceId == null && name === "") return [];
return [
{
id: evidenceId == null ? "evidence" : String(evidenceId),
name: name === "" ? `Document #${evidenceId}` : name,
url: "",
},
];
}
export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift {
const item = asRecord(raw);
const statusRaw = readString(item, "status", "Status");
@ -149,7 +197,8 @@ export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift {
),
decidedAt: readString(item, "decidedAt", "DecidedAt"),
decidedByName: readString(item, "decidedByName", "DecidedByName"),
decisionNote: readString(item, "decisionNote", "DecisionNote"),
decisionNote: readString(item, "decisionNote", "DecisionNote", "revokeReason", "RevokeReason"),
attachments: mapEvidenceAttachment(item),
};
}

View file

@ -16,6 +16,12 @@ export interface UpliftSummary {
amount?: number;
}
export interface WorkOrderUpliftAttachment {
id: string;
name: string;
url: string;
}
export interface WorkOrderUplift {
id: string | number;
status: WorkOrderUpliftStatus;
@ -27,12 +33,15 @@ export interface WorkOrderUplift {
decidedAt: string;
decidedByName: string;
decisionNote: string;
attachments?: WorkOrderUpliftAttachment[];
}
export interface CreateWorkOrderUpliftInput {
amount: number;
notes: string;
attachments?: File[];
files?: File[];
evidenceDocumentId?: number;
evidenceDocumentIds?: number[];
}
export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = {

View file

@ -0,0 +1,77 @@
import type { UpliftEvidenceSnapshot } from "@/domain/work-orders/api/work-order-uplifts-api";
import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
} from "@/domain/work-orders/types/work-order-uplift";
const POLL_INTERVAL_MS = 1000;
const MAX_POLL_ATTEMPTS = 30;
type CreateUpliftBody = {
amount: number;
notes: string;
evidenceDocumentId?: number;
evidenceDocumentIds?: number[];
};
export type SubmitWorkOrderUpliftDeps = {
uploadEvidence: (workOrderId: string | number, file: File) => Promise<UpliftEvidenceSnapshot>;
getEvidenceStatus: (
workOrderId: string | number,
documentId: number,
) => Promise<UpliftEvidenceSnapshot>;
create: (workOrderId: string | number, input: CreateUpliftBody) => Promise<WorkOrderUplift>;
sleep?: (ms: number) => Promise<void>;
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function scanFailureMessage(scanStatus: "Rejected" | "Timeout"): string {
if (scanStatus === "Rejected") {
return "The evidence file was rejected by the security scan.";
}
return "The evidence file is still being scanned. Try again in a moment.";
}
async function waitForPassedScan(
workOrderId: string | number,
file: File,
deps: SubmitWorkOrderUpliftDeps,
): Promise<number> {
const pause = deps.sleep ?? sleep;
const uploaded = await deps.uploadEvidence(workOrderId, file);
for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt += 1) {
const document =
attempt === 0 ? uploaded : await deps.getEvidenceStatus(workOrderId, uploaded.id);
if (document.scanStatus === "Passed") {
return uploaded.id;
}
if (document.scanStatus === "Rejected") {
throw new Error(scanFailureMessage("Rejected"));
}
await pause(POLL_INTERVAL_MS);
}
throw new Error(scanFailureMessage("Timeout"));
}
export async function submitWorkOrderUplift(
workOrderId: string | number,
input: CreateWorkOrderUpliftInput,
deps: SubmitWorkOrderUpliftDeps,
): Promise<WorkOrderUplift> {
const evidenceDocumentIds: number[] = [];
for (const file of input.files ?? []) {
evidenceDocumentIds.push(await waitForPassedScan(workOrderId, file, deps));
}
const [evidenceDocumentId] = evidenceDocumentIds;
return deps.create(workOrderId, {
amount: input.amount,
notes: input.notes,
evidenceDocumentId,
evidenceDocumentIds: evidenceDocumentIds.length > 0 ? evidenceDocumentIds : undefined,
});
}

View file

@ -9,6 +9,7 @@ import { isHTTPError, isNetworkError, isTimeoutError } from "ky";
import { toast } from "react-toastify";
import { mapHttpStatusToMessage, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error";
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
import { submitWorkOrderUplift } from "@/domain/work-orders/use-cases/submit-work-order-uplift";
import type {
CreateWorkOrderUpliftInput,
WorkOrderUplift,
@ -63,7 +64,12 @@ export function useCreateWorkOrderUplift(
return useMutation({
mutationFn: (input: CreateWorkOrderUpliftInput) =>
workOrderUpliftsApi.create(workOrderId, input),
submitWorkOrderUplift(workOrderId, input, {
uploadEvidence: (id, file) => workOrderUpliftsApi.uploadEvidence(id, file),
getEvidenceStatus: (id, documentId) =>
workOrderUpliftsApi.getEvidenceStatus(id, documentId),
create: (id, body) => workOrderUpliftsApi.create(id, body),
}),
onSuccess: () => {
invalidateUpliftQueries(queryClient, workOrderId);
toast.success("Uplift request created");

View file

@ -17,11 +17,6 @@ type AdvancedFilterPredicate = (
const isOpenUndated = (row: WorkOrderTableRow): boolean =>
!row.scheduledOn && !isTerminalCarryOverStatus(row.status);
// Mirrors the board search (ApplyDateRangeFilter and ApplyUnscheduledOnlyFilter)
// so the page and /board/search keep the same rows: a row is in a range
// when its date is, or when it is week-only and its target week overlaps. With
// no range, every week is searched and open undated rows are added. Closed
// undated rows are never unscheduled.
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
if (filters.unscheduledOnly) {
return isOpenUndated(row);

View file

@ -125,7 +125,6 @@ function mapSearchFacetParams(
/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
function mapWorkOrderIds(ids: Set<number> | undefined): string | undefined {
// Normalized here too, so a caller that fills the set directly still stays within the server's limit.
const normalized = ids?.size ? normalizeWorkOrderIds(ids) : [];
return normalized.length > 0 ? normalized.join(",") : undefined;
}
@ -151,13 +150,11 @@ function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
dateTo?: string;
includeDateless?: boolean;
} {
// SH-338: server-side unscheduledOnly owns the date-range exemption; the
// client must not fake a Custom window and sends no date params.
// why: SH-338: server-side unscheduledOnly owns the date-range exemption; the client must not fake a
// Custom window and sends no date params.
if (filters.unscheduledOnly) {
return {};
}
// No range selected searches every week, undated rows included. Any
// selected range (This week included) strictly narrows the results.
if (filters.dateRange === null) {
return {
datePreset: "Custom",

View file

@ -9,7 +9,7 @@ import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-dee
* someone else from that moment on, split the same way the feed splits them: comments that
* mention the user belong to Mentions, the rest to Recent activity.
*/
// Board comment times arrive as UTC without a zone designator; read them as UTC, not local time.
// why: Board comment times arrive as UTC without a zone designator; read them as UTC, not local time.
function parseUtc(value: string): number {
return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`);
}
@ -30,7 +30,7 @@ export function highlightedCommentIds(
if (Number.isNaN(createdAt) || createdAt < since || isOwnWorkOrderComment(comment, userId)) {
continue;
}
// Board comments expose mentions two ways: an encoded `@[id:name]` token in the text, or a
// why: Board comments expose mentions two ways: an encoded `@[id:name]` token in the text, or a
// `mentions` id array alongside plain `@Name` text. Treat either as a mention of the user.
const mentionIds = new Set(extractMentionIds(comment.commenttext ?? ""));
for (const id of comment.mentions ?? []) {

View file

@ -11,7 +11,7 @@ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const COMPLETION_DOC_FILTERS: CompletionDocFilter[] = ["No", "Yes", "NN"];
// SH-348: the board region filter (backend#146) accepts these region names.
// why: SH-348: the board region filter (backend#146) accepts these region names.
const BOARD_REGION_NAMES = ["East", "Central", "West", "California", "Unmapped/Other"];
const UPLIFT_FILTER_VALUES: UpliftFilterStatus[] = [
@ -76,7 +76,6 @@ export function buildSiteOpenWorkOrdersHref(siteId: string | number): string {
export function parseDashboardDrilldownParams(
params: URLSearchParams,
): WorkOrderAdvancedFilters | null {
// An exact id set replaces every other filter: nothing else may hide one of those work orders.
const workOrderIds = parseWorkOrderIdsParam(params.get("ids"));
if (workOrderIds.length > 0) {
return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) };

View file

@ -130,7 +130,7 @@ function formatGenericAuditFallback(args: {
if (oldValue.text) {
return `Changed ${label} from ${wrapAuditValue(oldValue)} to "—"`;
}
// Never surface a raw audit enum such as "FieldChanged" as the sentence.
// why: Never surface a raw audit enum such as "FieldChanged" as the sentence.
if (fieldName && action) return `Updated ${label}`;
return "Updated work order";
}

View file

@ -4,13 +4,9 @@ import type {
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
/**
* Fields compared for dirty state and included in Save patch.
* Only keys that map to board PATCH (or required companions like dispatcherName).
* Primary POC scalars persist via the dedicated POC endpoint;
* tech phone is not board-editable — exclude so Save cannot no-op.
* additionalContacts is board-editable (SH-218 PATCH field).
*/
// why: Primary POC scalars persist on the dedicated POC endpoint, so they stay out of this patch.
// Tech phone is not board-editable; including it would let Save no-op. additionalContacts stays
// because it is a board PATCH field (SH-218).
export const SLIDE_OVER_EDIT_KEYS = [
"woNumber",
"site",

View file

@ -18,9 +18,8 @@ export function computeIsPastDue(row: WorkOrderTableRow): boolean {
if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
return false;
}
// scheduledOn is a calendar date, so compare it to the user's local calendar
// day. A UTC "today" rolls over hours early west of UTC and flags work
// scheduled for today as Past Due every evening.
// why: scheduledOn is a calendar date, so compare it to the user's local calendar day. A UTC "today"
// rolls over hours early west of UTC and flags work scheduled for today as Past Due every evening.
return row.scheduledOn.slice(0, 10) < todayIso();
}

View file

@ -16,17 +16,26 @@ export function formatUpliftMoney(value: number | null | undefined): string {
}).format(value);
}
const STATUS_LABELS: Record<WorkOrderUpliftStatus, string> = {
const PILL_LABELS: Record<WorkOrderUpliftStatus, string> = {
pending: "Pending approval",
approved: "Approved",
auto_approved: "Auto-approved",
rejected: "Rejected",
cancelled: "Cancelled",
revoked: "Revoked",
};
const CELL_STATUS_WORD: Record<WorkOrderUpliftStatus, string> = {
pending: "Pending",
approved: "Approved",
auto_approved: "Auto",
auto_approved: "Approved",
rejected: "Rejected",
cancelled: "Cancelled",
revoked: "Revoked",
};
export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string {
return STATUS_LABELS[status];
return PILL_LABELS[status];
}
export function getUpliftCellLabel(summary: UpliftSummary): string | null {
@ -34,12 +43,15 @@ export function getUpliftCellLabel(summary: UpliftSummary): string | null {
return null;
}
if (summary.pendingCount > 0) {
if (summary.amount != null) {
return `${formatUpliftMoney(summary.amount)} Pending`;
}
return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`;
}
if (summary.primaryStatus) {
const label = formatUpliftStatusLabel(summary.primaryStatus);
const label = CELL_STATUS_WORD[summary.primaryStatus];
if (summary.amount != null) {
return `${label} ${formatUpliftMoney(summary.amount)}`;
return `${formatUpliftMoney(summary.amount)} ${label}`;
}
return label;
}
@ -57,14 +69,13 @@ export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): {
case "pending":
return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" };
case "approved":
return { background: "var(--status-success-bg, #DCFCE7)", color: "var(--color-success)" };
case "auto_approved":
return { background: "var(--color-primary-tint)", color: "var(--color-primary)" };
return { background: "var(--status-completed-bg)", color: "var(--status-completed-text)" };
case "rejected":
return { background: "#FEE2E2", color: "#B91C1C" };
return { background: "var(--status-pastdue-bg)", color: "var(--status-pastdue-text)" };
case "cancelled":
case "revoked":
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
return { background: "var(--status-canceled-bg)", color: "var(--status-canceled-text)" };
default:
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
}
@ -74,17 +85,63 @@ export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]):
return status === "Completed" || status === "Canceled";
}
export type UpliftAllowanceHelperTone = "muted" | "success" | "warning";
export type UpliftAllowanceHelperIcon = "check" | "alert" | null;
export type UpliftAllowanceHelper = {
text: string;
tone: UpliftAllowanceHelperTone;
tint: boolean;
icon: UpliftAllowanceHelperIcon;
};
export function upliftAllowanceHelper(
amount: number,
remainingAllowance: number,
): UpliftAllowanceHelper {
const typed = Number.isFinite(amount) && amount > 0;
if (!typed) {
if (remainingAllowance > 0) {
return {
text: `${formatUpliftMoney(remainingAllowance)} left to auto-approve`,
tone: "muted",
tint: false,
icon: null,
};
}
return {
text: "Limit used — new uplifts need admin approval",
tone: "warning",
tint: false,
icon: "alert",
};
}
if (amount <= remainingAllowance) {
return {
text: `Approves instantly · ${formatUpliftMoney(remainingAllowance - amount)} left after this`,
tone: "success",
tint: false,
icon: "check",
};
}
return {
text: `Needs admin approval · ${formatUpliftMoney(remainingAllowance)} limit`,
tone: "warning",
tint: true,
icon: "alert",
};
}
export function canRevokeWorkOrderUplift(
uplift: Pick<WorkOrderUplift, "status" | "requestedById">,
currentUserId: string | number | null | undefined,
currentUserIsAdmin: boolean,
): boolean {
if (uplift.status !== "auto_approved") {
// SH-214/SH-212: admin-approved revoke lives on Uplift Approvals, not the WO dialog.
// why: SH-214/SH-212 keep admin-approved revoke on Uplift Approvals, not this work order panel.
return false;
}
if (currentUserIsAdmin) {
// Admins revoke only admin-approved uplifts; an auto-approval is the requester's to revoke.
return false;
}
return (
@ -123,3 +180,90 @@ export function getRemainingUpliftAllowance(
.reduce((sum, uplift) => sum + uplift.amount, 0);
return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed);
}
function displayActor(name: string): string {
return name.trim() === "" ? "—" : name;
}
export function formatUpliftAbsoluteDate(iso: string): string {
if (iso.trim() === "") return "—";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
export function formatUpliftRelativeTime(iso: string, nowMs: number): string {
if (iso.trim() === "") return "—";
const timestamp = new Date(iso).getTime();
if (Number.isNaN(timestamp)) return "—";
const minutes = Math.floor((nowMs - timestamp) / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
type UpliftMetaSource = Pick<
WorkOrderUplift,
"status" | "requestedAt" | "requestedByName" | "decidedAt" | "decidedByName"
>;
export function formatUpliftCardMeta(uplift: UpliftMetaSource, nowMs: number): string {
const requester = displayActor(uplift.requestedByName);
const decider = displayActor(uplift.decidedByName);
if (uplift.status === "pending") {
return `${requester} · ${formatUpliftRelativeTime(uplift.requestedAt, nowMs)}`;
}
if (uplift.status === "auto_approved") {
return `Within allowance · ${requester} · ${formatUpliftAbsoluteDate(uplift.requestedAt)}`;
}
if (uplift.status === "approved") {
return `Approved by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`;
}
if (uplift.status === "rejected") {
return `Rejected by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`;
}
if (uplift.status === "revoked") {
return `Revoked by ${decider} · ${formatUpliftAbsoluteDate(uplift.decidedAt)}`;
}
const when = uplift.decidedAt.trim() === "" ? uplift.requestedAt : uplift.decidedAt;
return `Cancelled by ${requester} · ${formatUpliftAbsoluteDate(when)}`;
}
export function isVoidUpliftStatus(status: WorkOrderUpliftStatus): boolean {
return status === "rejected" || status === "cancelled" || status === "revoked";
}
export type UpliftCellVisual =
| { kind: "pending"; amount: string | null; statusLabel: "Pending" }
| { kind: "approved"; amount: string | null; statusLabel: "Approved" }
| { kind: "rejected"; amount: string | null; statusLabel: "Rejected" }
| { kind: "plain"; label: string };
export function getUpliftCellVisual(summary: UpliftSummary): UpliftCellVisual | null {
const label = getUpliftCellLabel(summary);
if (label == null) return null;
if (summary.pendingCount > 0) {
return {
kind: "pending",
amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null,
statusLabel: "Pending",
};
}
if (summary.primaryStatus === "approved" || summary.primaryStatus === "auto_approved") {
return {
kind: "approved",
amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null,
statusLabel: "Approved",
};
}
if (summary.primaryStatus === "rejected") {
return {
kind: "rejected",
amount: summary.amount != null ? formatUpliftMoney(summary.amount) : null,
statusLabel: "Rejected",
};
}
return { kind: "plain", label };
}

View file

@ -17,7 +17,6 @@ export function classifyUpload(f: File): {
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
return { fileType: "pdf", category: "Report" };
}
// Phones often send an empty or octet-stream type; fall back to the extension.
const kind = resolveWorkOrderUploadKind(f);
if (kind === "photo") return { fileType: "image", category: "Photo" };
if (kind === "video") return { fileType: "video", category: "Video" };

Some files were not shown because too many files have changed in this diff Show more