mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
Merge 64686a59eb into 6a6d519ed7
This commit is contained in:
commit
e8c3a8ef4f
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 |
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ?? "");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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" }],
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
|
|
@ -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([]),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
4
src/domain/vendors/api/vendors-api.ts
vendored
4
src/domain/vendors/api/vendors-api.ts
vendored
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
77
src/domain/work-orders/use-cases/submit-work-order-uplift.ts
Normal file
77
src/domain/work-orders/use-cases/submit-work-order-uplift.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ?? []) {
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue