mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 23:02:07 +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")) {
|
if (pathname.includes("/Dashboard/Stats")) {
|
||||||
// The server reports no unassigned work for a dispatcher-scoped view.
|
|
||||||
const unassigned = userRoles === "Dispatcher" ? 0 : 11;
|
const unassigned = userRoles === "Dispatcher" ? 0 : 11;
|
||||||
return fulfillJson(route, { data: { ...STATS, unassigned } });
|
return fulfillJson(route, { data: { ...STATS, unassigned } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ export const API_PATHS = {
|
||||||
`workorders/${id}/media/${mediaId}/content`,
|
`workorders/${id}/media/${mediaId}/content`,
|
||||||
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
completionDoc: (id: string | number) => `workorders/${id}/completion-doc`,
|
||||||
uplifts: (id: string | number) => `workorders/${id}/uplifts`,
|
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) =>
|
upliftCancel: (workOrderId: string | number, upliftId: string | number) =>
|
||||||
`workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
|
`workorders/${workOrderId}/uplifts/${upliftId}/cancel`,
|
||||||
upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>
|
upliftRevoke: (workOrderId: string | number, upliftId: string | number) =>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function formatDays(value: number): string {
|
||||||
return (Math.round(value * 10) / 10).toLocaleString();
|
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 {
|
function completionRateColor(rate: number): string {
|
||||||
if (rate >= 90) return "success.main";
|
if (rate >= 90) return "success.main";
|
||||||
if (rate >= 70) return "warning.main";
|
if (rate >= 70) return "warning.main";
|
||||||
|
|
@ -42,12 +42,10 @@ function DispatcherNameCell({
|
||||||
range: DashboardRangeKey;
|
range: DashboardRangeKey;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// A listed dispatcher is one with work in range; the drill-down filters Work
|
// why: A listed dispatcher is one with work in range; the drill-down filters Work Orders by dispatcher
|
||||||
// Orders by dispatcher + range and never uses the completion rate, so gate on
|
// + range and never uses the completion rate, so gate on identity rather than the rate. (A
|
||||||
// identity rather than the rate. (A dispatcher with assigned work but a 0%
|
// dispatcher with assigned work but a 0% rate was previously inert here while drillable from
|
||||||
// rate was previously inert here while drillable from Workload.) The gate stays
|
// 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.
|
||||||
// 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);
|
const drillable = Boolean(row.dispatcherId);
|
||||||
|
|
||||||
if (!drillable) {
|
if (!drillable) {
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,8 @@ export function StatusDistributionCard({
|
||||||
label: row.status,
|
label: row.status,
|
||||||
count: row.count,
|
count: row.count,
|
||||||
}))}
|
}))}
|
||||||
// Lifecycle labels the board can filter on are drillable; the "Unknown"
|
// why: Lifecycle labels the board can filter on are drillable; the "Unknown" bucket (unparseable
|
||||||
// bucket (unparseable statuses) has no board filter, so leave it inert.
|
// statuses) has no board filter, so leave it inert.
|
||||||
isSelectable={(row) => DRILLABLE_STATUSES.has(row.id)}
|
isSelectable={(row) => DRILLABLE_STATUSES.has(row.id)}
|
||||||
onSelectRow={(row) =>
|
onSelectRow={(row) =>
|
||||||
navigate(workOrderDrilldownUrl(workOrderStatusDrilldownSearch(row.id, range)))
|
navigate(workOrderDrilldownUrl(workOrderStatusDrilldownSearch(row.id, range)))
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ export function useDashboardScope(): DashboardScopeState {
|
||||||
|
|
||||||
const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null;
|
const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null;
|
||||||
|
|
||||||
// A range or scope change re-scopes both dispatcher tables, so their page must
|
// why: A range or scope change re-scopes both dispatcher tables, so their page must reset — otherwise a
|
||||||
// reset — otherwise a switch while on page 3 lands on an out-of-range empty
|
// switch while on page 3 lands on an out-of-range empty page that reads as a false "no
|
||||||
// page that reads as a false "no dispatchers" state.
|
// dispatchers" state.
|
||||||
const resetPages = () => {
|
const resetPages = () => {
|
||||||
setWorkloadPage(1);
|
setWorkloadPage(1);
|
||||||
setPerformancePage(1);
|
setPerformancePage(1);
|
||||||
|
|
@ -64,8 +64,8 @@ export function useDashboardScope(): DashboardScopeState {
|
||||||
resetPages();
|
resetPages();
|
||||||
};
|
};
|
||||||
|
|
||||||
// A Dispatcher's forced own-WOs scope never triggers the reset affordance (only
|
// why: A Dispatcher's forced own-WOs scope never triggers the reset affordance (only an active date
|
||||||
// an active date range can, for that role); a Scheduler/Admin's non-All scope does.
|
// range can, for that role); a Scheduler/Admin's non-All scope does.
|
||||||
const scopeIsFiltered = canViewAll && scope.mode !== "all";
|
const scopeIsFiltered = canViewAll && scope.mode !== "all";
|
||||||
const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered;
|
const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,8 @@ export function WorkOrdersByRegionCard({
|
||||||
}: WorkOrdersByRegionCardProps) {
|
}: WorkOrdersByRegionCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet
|
// why: SH-348 open point: the "Unmapped/Other" bucket is proposed but not yet confirmed with the client
|
||||||
// confirmed with the client ("do not implement until confirmed"), so only the
|
// ("do not implement until confirmed"), so only the four canonical zones are shown.
|
||||||
// four canonical zones are shown.
|
|
||||||
const zones = rows.filter((row) => row.region !== UNMAPPED_REGION_LABEL);
|
const zones = rows.filter((row) => row.region !== UNMAPPED_REGION_LABEL);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,7 @@ export function SiteDeleteDialog({
|
||||||
refetch,
|
refetch,
|
||||||
} = useSiteOpenWorkOrders(siteId, open);
|
} = useSiteOpenWorkOrders(siteId, open);
|
||||||
const openCount = openWorkOrders?.count ?? 0;
|
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;
|
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 openIds = openWorkOrders?.workOrderIds ?? [];
|
||||||
const openWorkOrdersHref =
|
const openWorkOrdersHref =
|
||||||
openIds.length > 0 && openIds.length >= openCount
|
openIds.length > 0 && openIds.length >= openCount
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,6 @@ export function SiteFormFields({
|
||||||
value={addressField.value}
|
value={addressField.value}
|
||||||
onInputChange={addressField.onChange}
|
onInputChange={addressField.onChange}
|
||||||
onSelect={(parts) => {
|
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);
|
addressField.onChange(parts.street);
|
||||||
cityField.onChange(parts.city);
|
cityField.onChange(parts.city);
|
||||||
stateField.onChange(normalizeStateCode(parts.state) ?? "");
|
stateField.onChange(normalizeStateCode(parts.state) ?? "");
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ export function openUpliftEvidence(row: UpliftQueueItem): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
|
export function openUpliftEvidenceInNewTab(row: UpliftQueueItem): void {
|
||||||
// Open the tab inside the click handler; a tab opened after the fetch resolves
|
// why: Open the tab inside the click handler; a tab opened after the fetch resolves is treated as an
|
||||||
// is treated as an unsolicited popup and blocked.
|
// unsolicited popup and blocked.
|
||||||
const tab = window.open("about:blank", "_blank");
|
const tab = window.open("about:blank", "_blank");
|
||||||
if (tab != null) {
|
if (tab != null) {
|
||||||
tab.opener = null;
|
tab.opener = null;
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ const APPROVED_HEADER_CELLS = [
|
||||||
"Actions",
|
"Actions",
|
||||||
] as const;
|
] 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 = [
|
const REJECTED_HEADER_CELLS = [
|
||||||
"Work Order",
|
"Work Order",
|
||||||
"Service / Vendor",
|
"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({
|
function UpliftApprovalsEmptyRow({
|
||||||
tab,
|
tab,
|
||||||
colSpan,
|
colSpan,
|
||||||
|
|
|
||||||
|
|
@ -321,8 +321,8 @@ function RevokeModalAction({
|
||||||
}
|
}
|
||||||
|
|
||||||
function useExposureBreakdown(item: UpliftQueueItem) {
|
function useExposureBreakdown(item: UpliftQueueItem) {
|
||||||
// Permission failures (e.g. 403 for account-scoped users) are handled quietly:
|
// why: Permission failures (e.g. 403 for account-scoped users) are handled quietly: the breakdown falls
|
||||||
// the breakdown falls back to "Unavailable" instead of toasting.
|
// back to "Unavailable" instead of toasting.
|
||||||
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
|
const woUpliftsQuery = useWorkOrderUplifts(item.workOrderId ?? null, {
|
||||||
suppressErrorToast: true,
|
suppressErrorToast: true,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,6 @@ const TAB_LABELS: Record<UpliftApprovalTab, string> = {
|
||||||
rejected: "Rejected",
|
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 {
|
function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null {
|
||||||
return query.data ? query.data.totalCount : null;
|
return query.data ? query.data.totalCount : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ export function useVendorRosterForm({
|
||||||
);
|
);
|
||||||
const { data: facets, isLoading: facetsLoading } = useVendorFacets();
|
const { data: facets, isLoading: facetsLoading } = useVendorFacets();
|
||||||
const save = useSaveVendorCompanyRoster();
|
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 canAssignArea = isAdminUser(useContext(AuthContext)?.user?.userRoles);
|
||||||
const [conflict, setConflict] = useState<VendorRosterConflict | null>(null);
|
const [conflict, setConflict] = useState<VendorRosterConflict | null>(null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@ export function toFormValues(roster: VendorCompanyRoster): VendorCompanyRosterFo
|
||||||
city: roster.city,
|
city: roster.city,
|
||||||
state: roster.state,
|
state: roster.state,
|
||||||
zip: roster.zip,
|
zip: roster.zip,
|
||||||
// The deprecated stored Maps URL is intentionally not seeded: the form has
|
// why: The deprecated stored Maps URL is intentionally not seeded: the form has no input for it, and
|
||||||
// no input for it, and carrying it would re-emit a legacy value on write.
|
// carrying it would re-emit a legacy value on write.
|
||||||
notes: roster.notes,
|
notes: roster.notes,
|
||||||
areaId: roster.areaId,
|
areaId: roster.areaId,
|
||||||
technicians: roster.technicians.map((technician) => ({
|
technicians: roster.technicians.map((technician) => ({
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,8 @@ function DrawerHeader({
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
|
||||||
// SH-271 replaced the separate "Open in Google Maps" row: the Street Address
|
// why: SH-271 replaced the separate "Open in Google Maps" row: the Street Address itself is the link
|
||||||
// itself is the link now, and the location is derived from the structured
|
// now, and the location is derived from the structured parts rather than a stored URL.
|
||||||
// parts rather than a stored URL.
|
|
||||||
const address = { street: roster.address, city: roster.city, state: roster.state };
|
const address = { street: roster.address, city: roster.city, state: roster.state };
|
||||||
const mapsUrl = mapsSearchUrl(address);
|
const mapsUrl = mapsSearchUrl(address);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,9 +167,9 @@ function CompanyFields({
|
||||||
value={streetField.value ?? ""}
|
value={streetField.value ?? ""}
|
||||||
onInputChange={streetField.onChange}
|
onInputChange={streetField.onChange}
|
||||||
onSelect={(parts) => {
|
onSelect={(parts) => {
|
||||||
// One pick fills all three. City and State are written through
|
// why: One pick fills all three. City and State are written through their own controllers so
|
||||||
// their own controllers so react-hook-form marks them dirty and
|
// react-hook-form marks them dirty and revalidates — a plain assignment would be discarded on
|
||||||
// revalidates — a plain assignment would be discarded on save.
|
// save.
|
||||||
streetField.onChange(parts.street);
|
streetField.onChange(parts.street);
|
||||||
cityField.onChange(parts.city);
|
cityField.onChange(parts.city);
|
||||||
stateField.onChange(normalizeStateCode(parts.state) ?? parts.state);
|
stateField.onChange(normalizeStateCode(parts.state) ?? parts.state);
|
||||||
|
|
|
||||||
|
|
@ -98,9 +98,9 @@ export function CompDocDialog({
|
||||||
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
|
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
|
||||||
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
|
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
|
||||||
|
|
||||||
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
|
// why: The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in one
|
||||||
// one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up
|
// transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up Generate
|
||||||
// Generate patch would carry the pre-upload version and 409 after a successful upload.
|
// patch would carry the pre-upload version and 409 after a successful upload.
|
||||||
const upload = useCompDocPdfUpload({
|
const upload = useCompDocPdfUpload({
|
||||||
workOrderId: row.id,
|
workOrderId: row.id,
|
||||||
rowVersion,
|
rowVersion,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ export function useCompDocPagination(
|
||||||
);
|
);
|
||||||
}, [blockCount]);
|
}, [blockCount]);
|
||||||
|
|
||||||
// Block content can change on any render (typing in the editor), so measure after each one.
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
measure();
|
measure();
|
||||||
});
|
});
|
||||||
|
|
@ -81,12 +80,10 @@ export function useCompDocPagination(
|
||||||
onPageChangeRef.current?.({ current, total });
|
onPageChangeRef.current?.({ current, total });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Any re-layout can move the sheet tops under the reader, even when the page count holds.
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
report();
|
report();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Width changes, late font/image loads and reflow do not re-render; observe them.
|
|
||||||
const layoutKey = pages.map((page) => page.join(",")).join("|");
|
const layoutKey = pages.map((page) => page.join(",")).join("|");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ export function SlideOverCommentsTab({
|
||||||
);
|
);
|
||||||
const firstHighlightedId = comments.find((comment) => highlightedIds.has(String(comment.id)))?.id;
|
const firstHighlightedId = comments.find((comment) => highlightedIds.has(String(comment.id)))?.id;
|
||||||
const firstHighlightedRef = useRef<HTMLDivElement | null>(null);
|
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.
|
// tab may already be mounted when a later notification re-opens this same work order.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = firstHighlightedRef.current;
|
const node = firstHighlightedRef.current;
|
||||||
|
|
|
||||||
|
|
@ -192,15 +192,12 @@ export function SlideOverExtrasTab({
|
||||||
}: SlideOverExtrasTabProps) {
|
}: SlideOverExtrasTabProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const avetaAttached = hasExplicitAvetaFile(uploads);
|
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 screeningRef = useRef(false);
|
||||||
const [screening, setScreening] = useState(false);
|
const [screening, setScreening] = useState(false);
|
||||||
const busy = pending || screening;
|
const busy = pending || screening;
|
||||||
|
|
||||||
const addFiles = async (files: FileList | null) => {
|
const addFiles = async (files: FileList | null) => {
|
||||||
if (isReadOnly || pending || screeningRef.current || !files || files.length === 0) return;
|
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);
|
const selected = Array.from(files);
|
||||||
screeningRef.current = true;
|
screeningRef.current = true;
|
||||||
setScreening(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) {
|
}: UseSlideOverEditStateArgs) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
|
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 [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [showUnsaved, setShowUnsaved] = useState(false);
|
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||||
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
|
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
|
||||||
|
|
||||||
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
|
// why: Reset draft/baseline only when the opened WO identity or editMode intent changes — not on every
|
||||||
// not on every optimistic parent-row field update during a failed/in-flight save.
|
// optimistic parent-row field update during a failed/in-flight save.
|
||||||
useSlideOverInitialization({
|
useSlideOverInitialization({
|
||||||
row,
|
row,
|
||||||
editMode,
|
editMode,
|
||||||
|
|
@ -109,9 +109,9 @@ export function useSlideOverEditState({
|
||||||
setUnsavedIntent,
|
setUnsavedIntent,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-apply the requested tab on every open, including a second notification for the work order
|
// 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
|
// already on screen (same row id) where the identity effect above does not re-run. Tab-only so an
|
||||||
// an in-flight edit draft is never discarded.
|
// in-flight edit draft is never discarded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
setTab(editMode ? "info" : initialTab);
|
setTab(editMode ? "info" : initialTab);
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,6 @@ export function useSlideOverMediaState({
|
||||||
[detailForRow?.media, ownership, completionHasGallery],
|
[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 mediaCounts = useMemo(() => countWorkOrderMediaKinds(uploads), [uploads]);
|
||||||
|
|
||||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
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 { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
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 = {
|
type UseWorkOrderSlideOverArgs = {
|
||||||
row: WorkOrderTableRow | null;
|
row: WorkOrderTableRow | null;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
|
initialTab?: SlideOverTab;
|
||||||
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
initialTab?: SlideOverTab;
|
|
||||||
openRequestKey?: number;
|
openRequestKey?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -56,10 +56,10 @@ function uploadSlideOverCompletionPdf({
|
||||||
toast.error("Missing work order version. Refresh and try again.");
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The upload transitions the completion document to Yes and bumps the work-order row
|
// why: The upload transitions the completion document to Yes and bumps the work-order row version in
|
||||||
// version in the same server transaction; the mutation's onSuccess refreshes the board.
|
// the same server transaction; the mutation's onSuccess refreshes the board. A client docStatus
|
||||||
// A client docStatus patch here would carry the pre-upload version and 409 after a
|
// patch here would carry the pre-upload version and 409 after a successful upload, so it is
|
||||||
// successful upload, so it is intentionally omitted.
|
// intentionally omitted.
|
||||||
mutate({ file, workOrderVersion: version });
|
mutate({ file, workOrderVersion: version });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,10 +89,10 @@ function slideOverErrorMessage(error: unknown) {
|
||||||
export function useWorkOrderSlideOver({
|
export function useWorkOrderSlideOver({
|
||||||
row,
|
row,
|
||||||
editMode,
|
editMode,
|
||||||
|
initialTab = "info",
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
initialTab,
|
|
||||||
openRequestKey,
|
openRequestKey,
|
||||||
}: UseWorkOrderSlideOverArgs) {
|
}: UseWorkOrderSlideOverArgs) {
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
|
|
@ -111,7 +111,7 @@ export function useWorkOrderSlideOver({
|
||||||
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
|
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
|
||||||
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
|
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
|
||||||
|
|
||||||
const [tab, setTab] = useState<SlideOverTab>("info");
|
const [tab, setTab] = useState<SlideOverTab>(initialTab);
|
||||||
const { detailForRow, infoSource } = resolveSlideOverSource(detail, row);
|
const { detailForRow, infoSource } = resolveSlideOverSource(detail, row);
|
||||||
const readFlags = slideOverReadFlags(infoSource?.status);
|
const readFlags = slideOverReadFlags(infoSource?.status);
|
||||||
|
|
||||||
|
|
@ -133,7 +133,8 @@ export function useWorkOrderSlideOver({
|
||||||
categorizeMediaMutation,
|
categorizeMediaMutation,
|
||||||
});
|
});
|
||||||
const completionPending = uploadCompletionMutation.isPending;
|
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({
|
const editState = useSlideOverEditState({
|
||||||
row,
|
row,
|
||||||
|
|
@ -146,7 +147,6 @@ export function useWorkOrderSlideOver({
|
||||||
initialTab,
|
initialTab,
|
||||||
openRequestKey,
|
openRequestKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadCompletionPdf = (file: File) => {
|
const uploadCompletionPdf = (file: File) => {
|
||||||
uploadSlideOverCompletionPdf({
|
uploadSlideOverCompletionPdf({
|
||||||
file,
|
file,
|
||||||
|
|
@ -179,6 +179,8 @@ export function useWorkOrderSlideOver({
|
||||||
mediaPending,
|
mediaPending,
|
||||||
completionPending,
|
completionPending,
|
||||||
closeDisabled,
|
closeDisabled,
|
||||||
|
upliftCreatePending,
|
||||||
|
setUpliftCreatePending,
|
||||||
uploadMediaRequests,
|
uploadMediaRequests,
|
||||||
uploadExtraFiles,
|
uploadExtraFiles,
|
||||||
categorizeMedia,
|
categorizeMedia,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import type { SlideOverTab } from "./use-work-order-slide-over";
|
||||||
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
|
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
|
||||||
{ id: "info", label: "Info" },
|
{ id: "info", label: "Info" },
|
||||||
{ id: "comments", label: "Comments" },
|
{ id: "comments", label: "Comments" },
|
||||||
{ id: "audit", label: "Audit Log" },
|
|
||||||
{ id: "completion", label: "Completion Doc" },
|
{ id: "completion", label: "Completion Doc" },
|
||||||
|
{ id: "uplifts", label: "Uplifts" },
|
||||||
{ id: "extras", label: "Extra Docs" },
|
{ id: "extras", label: "Extra Docs" },
|
||||||
|
{ id: "audit", label: "Audit Log" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type WorkOrderSlideOverTabNavProps = {
|
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 { 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 { 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 { 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 {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
|
|
@ -17,6 +18,31 @@ import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
|
||||||
|
|
||||||
type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
|
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 = {
|
type WorkOrderSlideOverTabsProps = {
|
||||||
tab: SlideOverTab;
|
tab: SlideOverTab;
|
||||||
state: SlideOverState;
|
state: SlideOverState;
|
||||||
|
|
@ -141,6 +167,13 @@ export function WorkOrderSlideOverTabs({
|
||||||
</WorkOrderSlideOverTabPanel>
|
</WorkOrderSlideOverTabPanel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SlideOverUpliftsPanel
|
||||||
|
tab={tab}
|
||||||
|
infoSource={infoSource}
|
||||||
|
upliftCreatePending={Boolean(state.upliftCreatePending)}
|
||||||
|
onUpliftCreatePendingChange={state.setUpliftCreatePending}
|
||||||
|
/>
|
||||||
|
|
||||||
{tab === "extras" && (
|
{tab === "extras" && (
|
||||||
<WorkOrderSlideOverTabPanel
|
<WorkOrderSlideOverTabPanel
|
||||||
variant="extras"
|
variant="extras"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export type SlideOverSaveOptions = {
|
||||||
type WorkOrderSlideOverProps = {
|
type WorkOrderSlideOverProps = {
|
||||||
row: WorkOrderTableRow | null;
|
row: WorkOrderTableRow | null;
|
||||||
editMode: boolean;
|
editMode: boolean;
|
||||||
|
initialTab?: SlideOverTab;
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
|
|
@ -36,7 +37,6 @@ type WorkOrderSlideOverProps = {
|
||||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
initialTab?: SlideOverTab;
|
|
||||||
/** Comments a notification opened the slide-over for; highlighted on the Comments tab. */
|
/** Comments a notification opened the slide-over for; highlighted on the Comments tab. */
|
||||||
commentHighlight?: CommentHighlight | null;
|
commentHighlight?: CommentHighlight | null;
|
||||||
/** Increments on every open so re-opening the same work order re-applies tab/highlight. */
|
/** Increments on every open so re-opening the same work order re-applies tab/highlight. */
|
||||||
|
|
@ -46,6 +46,7 @@ type WorkOrderSlideOverProps = {
|
||||||
export function WorkOrderSlideOver({
|
export function WorkOrderSlideOver({
|
||||||
row,
|
row,
|
||||||
editMode,
|
editMode,
|
||||||
|
initialTab = "info",
|
||||||
users,
|
users,
|
||||||
sites,
|
sites,
|
||||||
vendors,
|
vendors,
|
||||||
|
|
@ -58,7 +59,6 @@ export function WorkOrderSlideOver({
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
initialTab,
|
|
||||||
commentHighlight,
|
commentHighlight,
|
||||||
openRequestKey,
|
openRequestKey,
|
||||||
}: WorkOrderSlideOverProps) {
|
}: 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 type { UpliftSummary } from "@/domain/work-orders/types/work-order-uplift";
|
||||||
import {
|
import {
|
||||||
getUpliftCellLabel,
|
getUpliftCellLabel,
|
||||||
getUpliftStatusPillStyle,
|
getUpliftCellVisual,
|
||||||
|
type UpliftCellVisual,
|
||||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||||
|
|
||||||
type UpliftCellProps = {
|
type UpliftCellProps = {
|
||||||
summary: UpliftSummary;
|
summary: UpliftSummary;
|
||||||
|
closed?: boolean;
|
||||||
onOpen?: () => void;
|
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 CELL_DOT: Record<"pending" | "approved" | "rejected", string> = {
|
||||||
const label = getUpliftCellLabel(summary);
|
pending: "var(--warning)",
|
||||||
|
approved: "var(--success)",
|
||||||
|
rejected: "var(--destructive)",
|
||||||
|
};
|
||||||
|
|
||||||
if (!onOpen) {
|
const CELL_STATUS_COLOR: Record<"pending" | "approved" | "rejected", string> = {
|
||||||
return label ? (
|
pending: "var(--color-warning)",
|
||||||
<span className="text-[11px] text-[var(--color-text-muted)]">{label}</span>
|
approved: "var(--color-success)",
|
||||||
) : (
|
rejected: "var(--color-destructive)",
|
||||||
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pillStatus = summary.pendingCount > 0 ? "pending" : summary.primaryStatus;
|
|
||||||
const pillStyle = pillStatus ? getUpliftStatusPillStyle(pillStatus) : null;
|
|
||||||
|
|
||||||
|
function UpliftCellButton({
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
onOpen,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
onOpen: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
aria-label={label}
|
||||||
e.stopPropagation();
|
title={title}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
onOpen();
|
onOpen();
|
||||||
}}
|
}}
|
||||||
title={label || readOnly ? "View uplifts" : "Manage uplifts"}
|
className="flex w-full items-center justify-start gap-1.5 text-left text-[11px] font-semibold whitespace-nowrap"
|
||||||
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
>
|
>
|
||||||
{label && pillStyle ? (
|
{children}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</button>
|
</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: "vendor", label: "VENDOR", w: 184 },
|
||||||
{ key: "appt", label: "APPT TIME", w: 128 },
|
{ key: "appt", label: "APPT TIME", w: 128 },
|
||||||
{ key: "status", label: "STATUS", w: 148 },
|
{ 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: "completed", label: "COMPLETED DATE", w: 130 },
|
||||||
{ key: "doc", label: "COMP DOC", w: 84 },
|
{ key: "doc", label: "COMP DOC", w: 84 },
|
||||||
{ key: "uplift", label: "UPLIFT", w: 108 },
|
{ key: "uplift", label: "UPLIFT", w: 108 },
|
||||||
|
|
|
||||||
|
|
@ -132,8 +132,8 @@ export function WoTableRowServiceCells({
|
||||||
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||||
<UpliftCell
|
<UpliftCell
|
||||||
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
|
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
|
||||||
|
closed={isWorkOrderUpliftsReadOnly(row.status)}
|
||||||
onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined}
|
onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined}
|
||||||
readOnly={isWorkOrderUpliftsReadOnly(row.status)}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</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 { 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 { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
||||||
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-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 { 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 { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
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,
|
mediaCounts,
|
||||||
}: MediaUploaderProps) {
|
}: MediaUploaderProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
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 screeningRef = useRef(false);
|
||||||
const [screening, setScreening] = useState(false);
|
const [screening, setScreening] = useState(false);
|
||||||
|
|
||||||
const addFiles = async (files: FileList | null) => {
|
const addFiles = async (files: FileList | null) => {
|
||||||
if (readOnly || pending || screeningRef.current || !files || files.length === 0) return;
|
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);
|
const selected = Array.from(files);
|
||||||
screeningRef.current = true;
|
screeningRef.current = true;
|
||||||
setScreening(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 { useEffect, useState } from "react";
|
||||||
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
|
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 { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||||
|
|
@ -22,8 +23,8 @@ export function RevokeUpliftDialog({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
pending = false,
|
pending = false,
|
||||||
requireReason = false,
|
requireReason = false,
|
||||||
title = "Revoke auto-approved uplift?",
|
title = "Revoke this uplift?",
|
||||||
description = "This will mark the uplift as revoked. Optional reason is stored with the decision.",
|
description = "goes back to your auto-approval allowance. This is recorded in the log.",
|
||||||
}: RevokeUpliftDialogProps) {
|
}: RevokeUpliftDialogProps) {
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
|
@ -47,32 +48,43 @@ export function RevokeUpliftDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={guardDialogClose(pending, handleClose)} maxWidth="xs" fullWidth>
|
<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>
|
<DialogContent>
|
||||||
<Text as="p" variant="description" className="mb-3">
|
<Text as="p" variant="description" tone="muted" className="mb-3">
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
<FieldLabel htmlFor="wo-revoke-uplift-reason" required={requireReason}>
|
<FieldLabel htmlFor="wo-revoke-uplift-reason" required={requireReason}>
|
||||||
Reason
|
{requireReason ? "Reason" : "Reason (optional)"}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<TextField
|
<TextField
|
||||||
id="wo-revoke-uplift-reason"
|
id="wo-revoke-uplift-reason"
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
|
placeholder="Wrong amount, wrong quote..."
|
||||||
value={reason}
|
value={reason}
|
||||||
required={requireReason}
|
required={requireReason}
|
||||||
onChange={(event) => setReason(event.target.value)}
|
onChange={(event) => setReason(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions sx={{ padding: "12px 20px", gap: 1 }}>
|
||||||
<DialogCancelButton onClick={handleClose} disabled={pending} />
|
<DialogCancelButton onClick={handleClose} disabled={pending} />
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
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}
|
disabled={pending || reasonMissing}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
>
|
>
|
||||||
|
<Undo2 size={13} aria-hidden />
|
||||||
Revoke
|
Revoke
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</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 { useState } from "react";
|
||||||
import { Button, TextField } from "@mui/material";
|
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 { 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 = {
|
type WorkOrderUpliftCreateFormProps = {
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
remainingAllowance: number;
|
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 {
|
function canSubmitUplift(amount: number, notes: string): boolean {
|
||||||
const remaining = `Remaining auto-approval: ${formatUpliftMoney(remainingAllowance)}.`;
|
return Number.isFinite(amount) && amount > 0 && notes.trim().length > 0;
|
||||||
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
}
|
||||||
return remaining;
|
|
||||||
}
|
const ALLOWANCE_HINT_COLOR: Record<UpliftAllowanceHelperTone, string> = {
|
||||||
if (parsedAmount <= remainingAllowance) {
|
muted: "var(--muted-foreground)",
|
||||||
return `${remaining} This request will auto-approve.`;
|
success: "var(--status-completed-text)",
|
||||||
}
|
warning: "var(--warning)",
|
||||||
return `${remaining} This request will go to the admin approval queue.`;
|
};
|
||||||
|
|
||||||
|
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({
|
export function WorkOrderUpliftCreateForm({
|
||||||
|
|
@ -27,57 +60,69 @@ export function WorkOrderUpliftCreateForm({
|
||||||
}: WorkOrderUpliftCreateFormProps) {
|
}: WorkOrderUpliftCreateFormProps) {
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const parsedAmount = Number(amount);
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await onSubmit({ amount: parsedAmount, notes: notes.trim() });
|
await onSubmit({ amount: parsedAmount, notes: notes.trim(), files });
|
||||||
setAmount("");
|
setAmount("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setFiles([]);
|
||||||
} catch {
|
} catch {
|
||||||
// Mutation onError already toasts; keep form values after failure.
|
// why: the mutation toasts onError, so a failed submit keeps the typed values.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-[var(--color-border)] p-3">
|
<div>
|
||||||
<Text as="p" variant="label" className="mb-2">
|
<FieldLabel htmlFor="wo-uplift-amount" required>
|
||||||
Request uplift
|
Amount ($)
|
||||||
</Text>
|
</FieldLabel>
|
||||||
<div className="grid gap-2">
|
<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
|
<TextField
|
||||||
label="Amount"
|
id="wo-uplift-notes"
|
||||||
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"
|
|
||||||
size="small"
|
size="small"
|
||||||
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
rows={3}
|
||||||
|
placeholder="Describe the additional work and why it is needed..."
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(event) => setNotes(event.target.value)}
|
onChange={(event) => setNotes(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
</div>
|
||||||
label="Attachments"
|
|
||||||
size="small"
|
<UpliftAttachmentPicker files={files} onChange={setFiles} />
|
||||||
disabled
|
|
||||||
helperText="Attachment upload will be wired when BE accepts files."
|
<div className="mt-3 flex justify-end">
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
className="gap-1.5"
|
||||||
disabled={!canSubmit || pending}
|
disabled={!canSubmit || pending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleSubmit();
|
void handleSubmit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create uplift
|
<Plus size={13} aria-hidden />
|
||||||
|
Request uplift
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 { 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 {
|
import {
|
||||||
canRevokeWorkOrderUplift,
|
canRevokeWorkOrderUplift,
|
||||||
|
formatUpliftCardMeta,
|
||||||
formatUpliftMoney,
|
formatUpliftMoney,
|
||||||
formatUpliftStatusLabel,
|
formatUpliftStatusLabel,
|
||||||
getUpliftStatusPillStyle,
|
getUpliftStatusPillStyle,
|
||||||
|
isVoidUpliftStatus,
|
||||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
} 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 = {
|
type WorkOrderUpliftListItemProps = {
|
||||||
uplift: WorkOrderUplift;
|
uplift: WorkOrderUplift;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
|
@ -18,67 +47,203 @@ type WorkOrderUpliftListItemProps = {
|
||||||
onRevoke?: () => void;
|
onRevoke?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrderUpliftListItem({
|
function UpliftStatusPill({ uplift }: { uplift: WorkOrderUplift }) {
|
||||||
uplift,
|
|
||||||
readOnly,
|
|
||||||
currentUserId,
|
|
||||||
currentUserIsAdmin,
|
|
||||||
pendingAction = false,
|
|
||||||
onCancel,
|
|
||||||
onRevoke,
|
|
||||||
}: WorkOrderUpliftListItemProps) {
|
|
||||||
const pillStyle = getUpliftStatusPillStyle(uplift.status);
|
const pillStyle = getUpliftStatusPillStyle(uplift.status);
|
||||||
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
|
|
||||||
const showRevoke =
|
|
||||||
!readOnly &&
|
|
||||||
canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) &&
|
|
||||||
Boolean(onRevoke);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-[var(--color-border)] p-3">
|
<span
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
className="inline-flex shrink-0 items-center whitespace-nowrap"
|
||||||
<span
|
style={{
|
||||||
className="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold"
|
fontSize: 11,
|
||||||
style={pillStyle}
|
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)}
|
{file.name}
|
||||||
</span>
|
</a>
|
||||||
<Text as="span" variant="label">
|
|
||||||
{formatUpliftMoney(uplift.amount)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
{Boolean(uplift.notes) && (
|
|
||||||
<Text as="p" variant="description" className="mb-2">
|
|
||||||
{uplift.notes}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
{Boolean(uplift.requestedByName) && (
|
{!hasUrl && (
|
||||||
<Text as="p" variant="caption" tone="muted">
|
<button
|
||||||
Requested by {uplift.requestedByName}
|
type="button"
|
||||||
</Text>
|
onClick={downloadEvidence}
|
||||||
)}
|
className="min-w-0 flex-1 truncate text-left hover:underline"
|
||||||
{showCancel && (
|
style={{ fontSize: 12, color: "var(--color-text-primary)" }}
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="inherit"
|
|
||||||
disabled={pendingAction}
|
|
||||||
onClick={onCancel}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
>
|
||||||
Cancel pending
|
{file.name}
|
||||||
</Button>
|
</button>
|
||||||
)}
|
|
||||||
{showRevoke && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
color="error"
|
|
||||||
disabled={pendingAction}
|
|
||||||
onClick={onRevoke}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
|
||||||
Revoke
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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) => {
|
const createWorkOrder = useCreateWorkOrderFromWizard((createdDraft) => {
|
||||||
// Close the dialog first: the work-order create's isPending has already dropped, so
|
// hazard: Create's isPending has already dropped, so an open dialog re-enables Create and a
|
||||||
// holding the dialog open through the notes save would re-enable Create and unlock
|
// second click creates another work order. Close first; the notes save continues on its own.
|
||||||
// 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.
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
void saveCompanyNotes(createdDraft);
|
void saveCompanyNotes(createdDraft);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function buildCreatedTechniciansPatch(
|
||||||
): Partial<WorkOrderWizardDraft> {
|
): Partial<WorkOrderWizardDraft> {
|
||||||
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
|
const patch: Partial<WorkOrderWizardDraft> = { newTechnicians: [] };
|
||||||
const [only] = created;
|
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 === "") {
|
if (created.length === 1 && only != null && draft.vendorId === "") {
|
||||||
patch.vendorId = String(only.id);
|
patch.vendorId = String(only.id);
|
||||||
patch.vendorName = company;
|
patch.vendorName = company;
|
||||||
|
|
@ -57,9 +57,9 @@ export function useWizardCreateWithTechnicians({
|
||||||
createMutation.mutate(draft);
|
createMutation.mutate(draft);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Mirror the vendor roster schema so an unparseable phone is caught here with a
|
// why: Mirror the vendor roster schema so an unparseable phone is caught here with a clear message
|
||||||
// clear message instead of failing the roster PATCH and blocking Create with the
|
// instead of failing the roster PATCH and blocking Create with the raw backend sentence. A blank
|
||||||
// raw backend sentence. A blank phone stays valid, matching the modal path.
|
// phone stays valid, matching the modal path.
|
||||||
if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) {
|
if (pending.some((technician) => !isValidNorthAmericanPhone(technician.phone))) {
|
||||||
toast.error("Enter a 10-digit phone number for each new technician.");
|
toast.error("Enter a 10-digit phone number for each new technician.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -80,7 +80,7 @@ export function useWizardCreateWithTechnicians({
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
const patch = buildCreatedTechniciansPatch(draft, company, pending, created);
|
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);
|
patchDraft(patch);
|
||||||
createMutation.mutate({ ...draft, ...patch });
|
createMutation.mutate({ ...draft, ...patch });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!vendorId) {
|
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);
|
if (baseline) setBaseline(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -64,8 +64,8 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set
|
||||||
if (current.vendorId !== vendorId) {
|
if (current.vendorId !== vendorId) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
// Seed only empty content or content still holding the previous company's unedited
|
// why: Seed only empty content or content still holding the previous company's unedited notes; anything
|
||||||
// notes; anything else is the dispatcher's typing and must never be overwritten.
|
// else is the dispatcher's typing and must never be overwritten.
|
||||||
if (current.vendorNotes !== "" && current.vendorNotes !== baseline?.notes) {
|
if (current.vendorNotes !== "" && current.vendorNotes !== baseline?.notes) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +112,7 @@ export function useWizardVendorCompanyNotes(open: boolean, vendorId: string, set
|
||||||
try {
|
try {
|
||||||
await saveNotes.mutateAsync(update);
|
await saveNotes.mutateAsync(update);
|
||||||
} catch {
|
} 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({
|
onPatch({
|
||||||
type: t,
|
type: t,
|
||||||
severity: nextSeverityForType(draft.type, t, draft.severity),
|
severity: nextSeverityForType(draft.type, t, draft.severity),
|
||||||
// A registry service is scoped to its work-order type, so a service picked
|
// why: A registry service is scoped to its work-order type, so a service picked under the previous type
|
||||||
// under the previous type fails backend ResolveServiceAsync (SH-187/#131).
|
// fails backend ResolveServiceAsync (SH-187/#131). Clear the service (and its vendor, filtered by
|
||||||
// Clear the service (and its vendor, filtered by the service) on a type change.
|
// the service) on a type change.
|
||||||
...(typeChanged
|
...(typeChanged
|
||||||
? {
|
? {
|
||||||
pm: "",
|
pm: "",
|
||||||
|
|
|
||||||
|
|
@ -162,8 +162,8 @@ export function createCompDocDialogActions({
|
||||||
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes
|
// why: Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes and
|
||||||
// and bumps the work-order row version in one transaction. Reflect that in the UI and refresh
|
// 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
|
// 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.
|
// pre-upload row version and 409, surfacing an error right after a successful upload.
|
||||||
function markSignedPdfUploaded() {
|
function markSignedPdfUploaded() {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ export function useDashboardDrilldownFilters(
|
||||||
): boolean {
|
): boolean {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const appliedRef = useRef(false);
|
const appliedRef = useRef(false);
|
||||||
// Hold onApply in a ref so a caller passing a fresh closure each render does not
|
// why: Hold onApply in a ref so a caller passing a fresh closure each render does not re-run the apply
|
||||||
// re-run the apply effect; it should fire only when the URL search params change.
|
// effect; it should fire only when the URL search params change.
|
||||||
const onApplyRef = useRef(onApply);
|
const onApplyRef = useRef(onApply);
|
||||||
const [fromDashboard, setFromDashboard] = useState(false);
|
const [fromDashboard, setFromDashboard] = useState(false);
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ export function useDashboardDrilldownFilters(
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const drilldown = parseDashboardDrilldownParams(searchParams);
|
const drilldown = parseDashboardDrilldownParams(searchParams);
|
||||||
if (!drilldown) {
|
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;
|
appliedRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoar
|
||||||
() =>
|
() =>
|
||||||
buildResolvedBoardSearchParams({
|
buildResolvedBoardSearchParams({
|
||||||
advApplied: filters.advApplied,
|
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: "",
|
search: "",
|
||||||
page: filters.page,
|
page: filters.page,
|
||||||
pageSize: filters.pageSize,
|
pageSize: filters.pageSize,
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ function useSlideOverOpenState() {
|
||||||
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
||||||
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
|
const [slideOverTab, setSlideOverTab] = useState<SlideOverTab>("info");
|
||||||
const [slideOverHighlight, setSlideOverHighlight] = useState<CommentHighlight | null>(null);
|
const [slideOverHighlight, setSlideOverHighlight] = useState<CommentHighlight | null>(null);
|
||||||
// Bumps on every open() so the slide-over re-applies tab/highlight even when a second
|
// why: Bumps on every open() so the slide-over re-applies tab/highlight even when a second notification
|
||||||
// notification targets the work order already on screen (same row id).
|
// targets the work order already on screen (same row id).
|
||||||
const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0);
|
const [slideOverOpenRequestKey, setSlideOverOpenRequestKey] = useState(0);
|
||||||
|
|
||||||
const handleOpenSlideOver = (
|
const handleOpenSlideOver = (
|
||||||
|
|
@ -73,7 +73,6 @@ export function useWorkOrdersListPage() {
|
||||||
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
|
const [confirmComplete, setConfirmComplete] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [mediaRow, setMediaRow] = 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 [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||||
const filters = useWorkOrdersListFilters();
|
const filters = useWorkOrdersListFilters();
|
||||||
const tableData = useWorkOrdersTableData(filters);
|
const tableData = useWorkOrdersTableData(filters);
|
||||||
|
|
@ -117,10 +116,9 @@ export function useWorkOrdersListPage() {
|
||||||
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||||
}, [mediaRow, tableData.rows]);
|
}, [mediaRow, tableData.rows]);
|
||||||
|
|
||||||
const activeUpliftsRow = useMemo(() => {
|
const handleOpenUplifts = (row: WorkOrderTableRow) => {
|
||||||
if (!upliftsRow) return null;
|
handleOpenSlideOver(row, false, "uplifts");
|
||||||
return tableData.rows.find((row) => row.id === upliftsRow.id) ?? upliftsRow;
|
};
|
||||||
}, [upliftsRow, tableData.rows]);
|
|
||||||
|
|
||||||
const handleCloseSlideOver = () => {
|
const handleCloseSlideOver = () => {
|
||||||
closeSlideOverState();
|
closeSlideOverState();
|
||||||
|
|
@ -160,11 +158,11 @@ export function useWorkOrdersListPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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);
|
await patchFieldAsync(row.id, { status: "Completed" }, saved.row);
|
||||||
} catch {
|
} catch {
|
||||||
// The document is saved; the table mutation already surfaced the status error and
|
// why: The document is saved; the table mutation already surfaced the status error and refetched, so
|
||||||
// refetched, so the WO stays in its prior status for a manual retry.
|
// the WO stays in its prior status for a manual retry.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -183,7 +181,6 @@ export function useWorkOrdersListPage() {
|
||||||
setDocRow,
|
setDocRow,
|
||||||
mediaRow,
|
mediaRow,
|
||||||
setMediaRow,
|
setMediaRow,
|
||||||
setUpliftsRow,
|
|
||||||
completionDocs,
|
completionDocs,
|
||||||
filters,
|
filters,
|
||||||
tableData,
|
tableData,
|
||||||
|
|
@ -199,8 +196,8 @@ export function useWorkOrdersListPage() {
|
||||||
activeSlideOverRow,
|
activeSlideOverRow,
|
||||||
activeDocRow,
|
activeDocRow,
|
||||||
activeMediaRow,
|
activeMediaRow,
|
||||||
activeUpliftsRow,
|
|
||||||
handleOpenSlideOver,
|
handleOpenSlideOver,
|
||||||
|
handleOpenUplifts,
|
||||||
handleCloseSlideOver,
|
handleCloseSlideOver,
|
||||||
handleConfirmCancel,
|
handleConfirmCancel,
|
||||||
handleConfirmComplete,
|
handleConfirmComplete,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
});
|
});
|
||||||
const { displayRows, ...localUi } = local;
|
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(
|
const advResults = useMemo(
|
||||||
() => (advActive ? displayRows : EMPTY_WEEKLY_ITEMS),
|
() => (advActive ? displayRows : EMPTY_WEEKLY_ITEMS),
|
||||||
[advActive, displayRows],
|
[advActive, displayRows],
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default function WorkOrdersListPage() {
|
||||||
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters);
|
||||||
useWorkOrderDeepLink({
|
useWorkOrderDeepLink({
|
||||||
onOpenSlideOver: page.handleOpenSlideOver,
|
onOpenSlideOver: page.handleOpenSlideOver,
|
||||||
onOpenUplifts: page.setUpliftsRow,
|
onOpenUplifts: page.handleOpenUplifts,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -116,7 +116,7 @@ export default function WorkOrdersListPage() {
|
||||||
onRequestComplete={page.setConfirmComplete}
|
onRequestComplete={page.setConfirmComplete}
|
||||||
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
onOpenCompDoc={(row) => openCompletionDoc(row, page.setDocRow)}
|
||||||
onOpenMedia={page.setMediaRow}
|
onOpenMedia={page.setMediaRow}
|
||||||
onOpenUplifts={page.setUpliftsRow}
|
onOpenUplifts={page.handleOpenUplifts}
|
||||||
onClearSearch={() => filters.handleSearchChange("")}
|
onClearSearch={() => filters.handleSearchChange("")}
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
onPatch={page.patchField}
|
onPatch={page.patchField}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ export function InviteWizard({ token, details, onInviteInvalid }: InviteWizardPr
|
||||||
onSuccess: () => setStep(2),
|
onSuccess: () => setStep(2),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (endedInvite(error)) return;
|
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") {
|
if (inviteErrorCode(error) === "resend_too_soon") {
|
||||||
setStep(2);
|
setStep(2);
|
||||||
return;
|
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") {
|
if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ function statusClassName(status: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function availableActions(request: VendorPortalUpliftRequest, locked: boolean, canAct: boolean) {
|
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;
|
const actionable = !locked && canAct && request.raisedByVendor !== false;
|
||||||
return {
|
return {
|
||||||
canWithdraw: actionable && WITHDRAWABLE_STATUSES.has(request.status),
|
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 {
|
function requestedTotal(request: VendorPortalUpliftRequest): number {
|
||||||
const requested = Number(request.requestedNTE);
|
const requested = Number(request.requestedNTE);
|
||||||
return request.raisedByVendor === false ? Number(request.currentNTE ?? 0) + requested : requested;
|
return request.raisedByVendor === false ? Number(request.currentNTE ?? 0) + requested : requested;
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ export function UpliftRequestForm({
|
||||||
|
|
||||||
const submit = (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// The picked file is not in state until its check settles; submitting now would validate the old one.
|
|
||||||
if (checkingEvidence) return;
|
if (checkingEvidence) return;
|
||||||
const amount = Number.parseFloat(requested);
|
const amount = Number.parseFloat(requested);
|
||||||
const validationError = validateUpliftAmount(requested, currentNte, reason, {
|
const validationError = validateUpliftAmount(requested, currentNte, reason, {
|
||||||
|
|
@ -69,7 +68,6 @@ export function UpliftRequestForm({
|
||||||
const selection = ++latestSelection.current;
|
const selection = ++latestSelection.current;
|
||||||
setCheckingEvidence(Boolean(file));
|
setCheckingEvidence(Boolean(file));
|
||||||
const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
|
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;
|
if (selection !== latestSelection.current) return;
|
||||||
setFormError(validationMessage ?? "");
|
setFormError(validationMessage ?? "");
|
||||||
setEvidenceFile(file);
|
setEvidenceFile(file);
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,6 @@ export function VendorPortalDocuments({
|
||||||
const [replacementId, setReplacementId] = useState<number>();
|
const [replacementId, setReplacementId] = useState<number>();
|
||||||
const [progress, setProgress] = useState<number>();
|
const [progress, setProgress] = useState<number>();
|
||||||
const [validationError, setValidationError] = useState<string>();
|
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 selectionRef = useRef(0);
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
|
|
|
||||||
|
|
@ -50,16 +50,14 @@ export function AddressAutocompleteField({
|
||||||
freeSolo
|
freeSolo
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
options={suggestAddresses(value)}
|
options={suggestAddresses(value)}
|
||||||
// The options are already derived from the input; letting MUI filter them
|
// why: The options are already derived from the input; letting MUI filter them again would drop every
|
||||||
// again would drop every row whose label does not literally contain the
|
// row whose label does not literally contain the typed text.
|
||||||
// typed text.
|
|
||||||
filterOptions={(options) => options}
|
filterOptions={(options) => options}
|
||||||
inputValue={value}
|
inputValue={value}
|
||||||
onInputChange={(_event, next, reason) => {
|
onInputChange={(_event, next, reason) => {
|
||||||
// Genuine typing and the clear control update the street. MUI also
|
// why: Genuine typing and the clear control update the street. MUI also fires this callback with reason
|
||||||
// fires this callback with reason "reset" after a selection, carrying
|
// "reset" after a selection, carrying the option's full "<street>, <city>, <state>" label —
|
||||||
// the option's full "<street>, <city>, <state>" label — forwarding
|
// forwarding that would overwrite the just-picked street with the label.
|
||||||
// that would overwrite the just-picked street with the label.
|
|
||||||
if (reason !== "input" && reason !== "clear") return;
|
if (reason !== "input" && reason !== "clear") return;
|
||||||
onInputChange(next);
|
onInputChange(next);
|
||||||
}}
|
}}
|
||||||
|
|
@ -81,8 +79,6 @@ export function AddressAutocompleteField({
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
fullWidth
|
fullWidth
|
||||||
required={requiredMarker}
|
required={requiredMarker}
|
||||||
// Required is set on the input itself; MUI's `required` prop only
|
|
||||||
// adds the label asterisk, which Vendors must not render.
|
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...params.slotProps,
|
...params.slotProps,
|
||||||
htmlInput: { ...params.slotProps?.htmlInput, required },
|
htmlInput: { ...params.slotProps?.htmlInput, required },
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ export function AddressMapPreview({
|
||||||
height = 160,
|
height = 160,
|
||||||
placeholderWhenIncomplete = false,
|
placeholderWhenIncomplete = false,
|
||||||
}: AddressMapPreviewProps) {
|
}: AddressMapPreviewProps) {
|
||||||
// Structured input is held to the completeness rule; a pre-composed line is
|
// why: Structured input is held to the completeness rule; a pre-composed line is saved data and only
|
||||||
// saved data and only has to be non-empty.
|
// has to be non-empty.
|
||||||
const query = address ? buildAddressQuery(address) : (rawQuery ?? "").trim();
|
const query = address ? buildAddressQuery(address) : (rawQuery ?? "").trim();
|
||||||
const complete = address ? hasCompleteAddress(address) : Boolean(query);
|
const complete = address ? hasCompleteAddress(address) : Boolean(query);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ type NotificationFeedActionsProps = {
|
||||||
|
|
||||||
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
|
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
|
||||||
const hasUnread = center.unreadIds.size > 0;
|
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));
|
const hasItems = center.sections.some((section) => section.items.some(isDismissable));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ const ICON_PROPS = {
|
||||||
style: { color: "var(--color-text-muted)", marginTop: 2 },
|
style: { color: "var(--color-text-muted)", marginTop: 2 },
|
||||||
} as const;
|
} 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 }) {
|
function ItemIcon({ item }: { item: NotificationItem }) {
|
||||||
if (item.reason === "activity" && item.target.tab === "uplifts") {
|
if (item.reason === "activity" && item.target.tab === "uplifts") {
|
||||||
return <CircleDollarSign {...ICON_PROPS} />;
|
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 = {
|
const approvalsNavGroup: NavGroup = {
|
||||||
title: "APPROVALS",
|
title: "APPROVALS",
|
||||||
items: [{ label: "Uplift Approvals", icon: BadgeDollarSign, path: "/uplifts" }],
|
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);
|
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> {
|
export async function forgotPassword(email: string): Promise<unknown> {
|
||||||
return apiPost(API_PATHS.authentication.forgetPassword, { email });
|
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> {
|
export async function verifyResetCode(email: string, code: string): Promise<unknown> {
|
||||||
return apiPost(API_PATHS.authentication.verificationCode, { email, code });
|
return apiPost(API_PATHS.authentication.verificationCode, { email, code });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,8 @@ export interface CompletionTemplateLegacyFields {
|
||||||
|
|
||||||
export interface CompletionTemplate {
|
export interface CompletionTemplate {
|
||||||
id: number;
|
id: number;
|
||||||
/** The Document Title. */
|
|
||||||
name: string;
|
name: string;
|
||||||
extraSafetyNote: string | null;
|
extraSafetyNote: string | null;
|
||||||
/** In print order. */
|
|
||||||
procedures: CompletionTemplateProcedure[];
|
procedures: CompletionTemplateProcedure[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** `updatedAt ?? createdAt`: what the table shows as Last Updated. */
|
/** `updatedAt ?? createdAt`: what the table shows as Last Updated. */
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ export function isTitleRequiredError(error: Error): boolean {
|
||||||
|
|
||||||
function invalidateTemplates(queryClient: QueryClient) {
|
function invalidateTemplates(queryClient: QueryClient) {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.completionTemplates.all });
|
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 });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,9 @@ export function scheduledTomorrowDrilldownSearch(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function avetaPendingDrilldownSearch(today: string = businessTodayIso()): URLSearchParams {
|
export function avetaPendingDrilldownSearch(today: string = businessTodayIso()): URLSearchParams {
|
||||||
// The Aveta Pending count (backend#138 / SH-294) is: AvetaRequired, scheduled
|
// why: Aveta Pending (SH-294) is Aveta-required, open, today or tomorrow, missing that document.
|
||||||
// today or tomorrow, open, and missing its Aveta-category document. The board
|
// The board cannot filter a missing Aveta document, so the link is that two-day open set.
|
||||||
// has no missing-Aveta-document filter, so the closest reachable set is the
|
// hazard: do not send docs=No — that is completion-doc status, a different document.
|
||||||
// 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.
|
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
search.set("dateFrom", today);
|
search.set("dateFrom", today);
|
||||||
search.set("dateTo", addDaysIso(today, 1));
|
search.set("dateTo", addDaysIso(today, 1));
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,6 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
|
||||||
state: item.state ?? "",
|
state: item.state ?? "",
|
||||||
zipCode: item.zipCode ?? "",
|
zipCode: item.zipCode ?? "",
|
||||||
phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "",
|
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(),
|
contact: mainContact ? mainContact.name : readString(item, "contact").trim(),
|
||||||
contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "",
|
contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "",
|
||||||
accountId: item.accountId ?? "",
|
accountId: item.accountId ?? "",
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@ export interface LocationContact {
|
||||||
export interface LocationListItem {
|
export interface LocationListItem {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
name: string;
|
name: string;
|
||||||
/** Client (account) display name. */
|
|
||||||
clientName: string;
|
clientName: string;
|
||||||
/** Site Phone, independent of the contacts. */
|
|
||||||
sitePhone: string;
|
sitePhone: string;
|
||||||
address: string;
|
address: string;
|
||||||
city: string;
|
city: string;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ export function NotificationSessionProvider({ children }: { children: ReactNode
|
||||||
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
|
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
|
||||||
() => new Set(),
|
() => 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 toastedSlaIds = useRef(new Set<number>());
|
||||||
|
|
||||||
const dismiss = useCallback((ids: string[]) => {
|
const dismiss = useCallback((ids: string[]) => {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const itemSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
reason: z.string(),
|
reason: z.string(),
|
||||||
severity: severitySchema,
|
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"]),
|
rowType: z.enum(["dismissable", "acknowledge"]),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
count: z.number(),
|
count: z.number(),
|
||||||
|
|
@ -63,7 +63,6 @@ const slaAtRiskSchema = z.object({
|
||||||
export const notificationFeedSchema = z.object({
|
export const notificationFeedSchema = z.object({
|
||||||
generatedAt: z.string(),
|
generatedAt: z.string(),
|
||||||
sections: z.array(sectionSchema),
|
sections: z.array(sectionSchema),
|
||||||
// Defaulted so a feed from a server without SLA alerts still parses.
|
|
||||||
slaAtRisk: z.array(slaAtRiskSchema).default([]),
|
slaAtRisk: z.array(slaAtRiskSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export function useNotificationFeed() {
|
||||||
queryKey: queryKeys.notifications.feed(),
|
queryKey: queryKeys.notifications.feed(),
|
||||||
queryFn: notificationsApi.feed,
|
queryFn: notificationsApi.feed,
|
||||||
refetchInterval: NOTIFICATION_FEED_REFRESH_MS,
|
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 },
|
meta: { suppressErrorToast: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ const SERVICE_WORK_ORDER_TYPE_API = {
|
||||||
Reactive: 6,
|
Reactive: 6,
|
||||||
} as const;
|
} 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> = {
|
const TYPE_TO_API: Record<WOType, number> = {
|
||||||
PM: WORK_ORDER_TYPE_API.PM,
|
PM: WORK_ORDER_TYPE_API.PM,
|
||||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,9 @@ export const EMPTY_SERVICE_FORM: ServiceForm = {
|
||||||
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
|
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not
|
// why: Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not the legacy
|
||||||
// the legacy DropdownOptions seed. A trade with no explicit default falls back
|
// DropdownOptions seed. A trade with no explicit default falls back to "wrench" where this map is
|
||||||
// to "wrench" where this map is read.
|
// read.
|
||||||
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
|
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
|
||||||
HVAC: "droplets",
|
HVAC: "droplets",
|
||||||
"Plumbing & Water Systems": "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
|
// why: Owns the add/edit form lifecycle so the top-level controller stays within the changed-file
|
||||||
// changed-file maintainability budget. Mutations are passed in so `isSaving` and
|
// maintainability budget. Mutations are passed in so `isSaving` and `closeEditor` share the exact
|
||||||
// `closeEditor` share the exact instances the controller reports on.
|
// instances the controller reports on.
|
||||||
function useServiceEditor(
|
function useServiceEditor(
|
||||||
canonicalTrades: string[],
|
canonicalTrades: string[],
|
||||||
canDeactivate: boolean,
|
canDeactivate: boolean,
|
||||||
|
|
@ -102,9 +102,9 @@ function useServiceEditor(
|
||||||
const [iconTouched, setIconTouched] = useState(false);
|
const [iconTouched, setIconTouched] = useState(false);
|
||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
|
|
||||||
// Keep the trade stored on the service being edited selectable even if it was
|
// why: Keep the trade stored on the service being edited selectable even if it was later deactivated in
|
||||||
// later deactivated in the canonical Trade catalog, so editing never silently
|
// the canonical Trade catalog, so editing never silently blanks the field and forces an unrelated
|
||||||
// blanks the field and forces an unrelated trade change.
|
// trade change.
|
||||||
const trades = useMemo(() => {
|
const trades = useMemo(() => {
|
||||||
if (form.trade && !canonicalTrades.includes(form.trade)) {
|
if (form.trade && !canonicalTrades.includes(form.trade)) {
|
||||||
return [form.trade, ...canonicalTrades];
|
return [form.trade, ...canonicalTrades];
|
||||||
|
|
@ -208,11 +208,10 @@ export function useServicesRegistryController() {
|
||||||
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
|
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
|
||||||
|
|
||||||
const servicesQuery = useServices();
|
const servicesQuery = useServices();
|
||||||
// The backend rejects any Trade outside the canonical TradeCatalog (400
|
// why: The backend rejects any Trade outside the canonical TradeCatalog (400 TradeInvalid), so the
|
||||||
// TradeInvalid), so the picker is sourced from the server-owned canonical
|
// picker is sourced from the server-owned canonical Trade reference exposed on Vendor/facets — the
|
||||||
// Trade reference exposed on Vendor/facets — the same list the vendor roster
|
// same list the vendor roster form uses — never the editable DropdownOptions "Trade" category,
|
||||||
// form uses — never the editable DropdownOptions "Trade" category, whose
|
// whose legacy seed values ("Plumbing", "General", ...) would fail on save.
|
||||||
// legacy seed values ("Plumbing", "General", ...) would fail on save.
|
|
||||||
const facetsQuery = useVendorFacets();
|
const facetsQuery = useVendorFacets();
|
||||||
const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]);
|
const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]);
|
||||||
const { data: templates = [] } = useCompletionDocTemplates();
|
const { data: templates = [] } = useCompletionDocTemplates();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
/**
|
// invariant: the invite token travels only in request bodies, never in a URL path or query.
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
import { isHTTPError } from "ky";
|
import { isHTTPError } from "ky";
|
||||||
|
|
||||||
import { apiPost, apiPostNoContent } from "@/api/api";
|
import { apiPost, apiPostNoContent } from "@/api/api";
|
||||||
|
|
@ -34,7 +31,6 @@ export const teamMemberInviteApi = {
|
||||||
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.complete, {
|
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.complete, {
|
||||||
token: input.token,
|
token: input.token,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
// An empty value clears the phone the admin entered.
|
|
||||||
phone: input.phone.trim(),
|
phone: input.phone.trim(),
|
||||||
});
|
});
|
||||||
const user = handleApiResponse<AuthUser>(data);
|
const user = handleApiResponse<AuthUser>(data);
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,6 @@ export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[
|
||||||
export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail {
|
export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail {
|
||||||
const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
const listItem = mapTeamMemberListItem(record);
|
const listItem = mapTeamMemberListItem(record);
|
||||||
// Present only on an update that re-sent a pending member's invite.
|
|
||||||
const inviteEmailSent = record.inviteEmailSent ?? record.InviteEmailSent;
|
const inviteEmailSent = record.inviteEmailSent ?? record.InviteEmailSent;
|
||||||
return {
|
return {
|
||||||
...listItem,
|
...listItem,
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,9 @@ export const upliftsApi = {
|
||||||
downloadEvidence: async (
|
downloadEvidence: async (
|
||||||
id: string | number,
|
id: string | number,
|
||||||
fallbackFileName = "uplift-evidence",
|
fallbackFileName = "uplift-evidence",
|
||||||
|
documentId?: string | number,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence");
|
const response = await fetchEvidence(id, "upliftsApi.downloadEvidence", documentId);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
|
saveBlob(blob, readContentDispositionFilename(response, fallbackFileName));
|
||||||
},
|
},
|
||||||
|
|
@ -120,8 +121,9 @@ export const upliftsApi = {
|
||||||
id: string | number,
|
id: string | number,
|
||||||
tab: Window | null,
|
tab: Window | null,
|
||||||
fallbackFileName = "uplift-evidence",
|
fallbackFileName = "uplift-evidence",
|
||||||
|
documentId?: string | number,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const response = await fetchEvidence(id, "upliftsApi.openEvidence");
|
const response = await fetchEvidence(id, "upliftsApi.openEvidence", documentId);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const type = blob.type.split(";")[0].trim().toLowerCase();
|
const type = blob.type.split(";")[0].trim().toLowerCase();
|
||||||
if (tab == null || !INLINE_EVIDENCE_TYPES.has(type)) {
|
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;
|
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 {
|
try {
|
||||||
return await apiRequestRaw("get", evidenceUrl(id), undefined, operation);
|
return await apiRequestRaw("get", evidenceUrl(id), options, operation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HTTPError) {
|
if (error instanceof HTTPError) {
|
||||||
throw evidenceHttpError(error.response.status);
|
throw evidenceHttpError(error.response.status);
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ function invalidateUpliftQueries(
|
||||||
dispatchId?: string | number,
|
dispatchId?: string | number,
|
||||||
) {
|
) {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.all });
|
||||||
// Queue decisions update the same uplift records rendered by Work Order views.
|
// why: Queue decisions update the same uplift records rendered by Work Order views. Invalidate that
|
||||||
// Invalidate that shared cache even when the queue has no dispatch context.
|
// shared cache even when the queue has no dispatch context.
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
if (dispatchId !== undefined && dispatchId !== "") {
|
if (dispatchId !== undefined && dispatchId !== "") {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.uplifts.forDispatch(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>),
|
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
|
||||||
queryFn: () => upliftsApi.list(params),
|
queryFn: () => upliftsApi.list(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
// The Uplift Approvals page reports a failed queue inline, so the global toast
|
|
||||||
// would announce the same failure twice.
|
|
||||||
meta: { suppressErrorToast: true },
|
meta: { suppressErrorToast: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ export const VENDOR_UPLOAD_ACCEPT =
|
||||||
|
|
||||||
type VendorUploadKind = "document" | "photo" | "video";
|
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> = {
|
const KIND_BY_MIME: Record<string, VendorUploadKind> = {
|
||||||
"application/pdf": "document",
|
"application/pdf": "document",
|
||||||
"image/jpeg": "photo",
|
"image/jpeg": "photo",
|
||||||
|
|
@ -114,7 +112,6 @@ export async function validateVendorDocument(
|
||||||
if (file.size > MAX_VIDEO_BYTES) {
|
if (file.size > MAX_VIDEO_BYTES) {
|
||||||
return "Videos must be 100 MB or smaller.";
|
return "Videos must be 100 MB or smaller.";
|
||||||
}
|
}
|
||||||
// Pre-check; the server enforces it too. Unreadable metadata never blocks.
|
|
||||||
const duration = await probeVideoDurationSeconds(file);
|
const duration = await probeVideoDurationSeconds(file);
|
||||||
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
|
if (duration != null && duration > MAX_VIDEO_DURATION_SECONDS) {
|
||||||
return `Videos must be ${MAX_VIDEO_DURATION_SECONDS} seconds or shorter.`;
|
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;
|
// why: SH-281: grouped company rows come from the dedicated directory endpoint; Vendor/GetVendorList
|
||||||
// Vendor/GetVendorList stays on the legacy technician-level contract.
|
// stays on the legacy technician-level contract.
|
||||||
getDirectoryList: async (params: VendorsListParams = {}): Promise<VendorsListResult> => {
|
getDirectoryList: async (params: VendorsListParams = {}): Promise<VendorsListResult> => {
|
||||||
const data = await apiGet<unknown>(API_PATHS.vendor.getDirectoryList, {
|
const data = await apiGet<unknown>(API_PATHS.vendor.getDirectoryList, {
|
||||||
searchParams: buildListParams(params),
|
searchParams: buildListParams(params),
|
||||||
|
|
|
||||||
|
|
@ -131,15 +131,15 @@ export function mapVendorRosterToBackend(values: unknown): Record<string, unknow
|
||||||
city: readString(item, "city", "City"),
|
city: readString(item, "city", "City"),
|
||||||
state: readString(item, "state", "State"),
|
state: readString(item, "state", "State"),
|
||||||
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
zip: readString(item, "zip", "Zip", "zipCode", "ZipCode"),
|
||||||
// The stored Maps URL is deprecated: reads keep it for legacy records, but
|
// why: The stored Maps URL is deprecated: reads keep it for legacy records, but roster writes must not
|
||||||
// roster writes must not carry it back to the backend.
|
// carry it back to the backend.
|
||||||
notes: readString(item, "notes", "Notes"),
|
notes: readString(item, "notes", "Notes"),
|
||||||
technicians: Array.isArray(techniciansRaw)
|
technicians: Array.isArray(techniciansRaw)
|
||||||
? techniciansRaw.map(mapRosterTechnicianToBackend)
|
? techniciansRaw.map(mapRosterTechnicianToBackend)
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
// The form always round-trips its Area (id or null = Unassigned). Callers
|
// why: The form always round-trips its Area (id or null = Unassigned). Callers without an Area value
|
||||||
// without an Area value omit it, which the API treats as "leave unchanged".
|
// omit it, which the API treats as "leave unchanged".
|
||||||
if ("areaId" in item || "AreaId" in item) {
|
if ("areaId" in item || "AreaId" in item) {
|
||||||
payload.areaId = toBackendAreaId(item.areaId ?? item.AreaId);
|
payload.areaId = toBackendAreaId(item.areaId ?? item.AreaId);
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +173,7 @@ export function mapVendorRosterAdditivePatchToBackend(
|
||||||
const companyFields: Record<string, unknown> = { ...patch.companyFields };
|
const companyFields: Record<string, unknown> = { ...patch.companyFields };
|
||||||
if (companyFields.companyPhone !== undefined)
|
if (companyFields.companyPhone !== undefined)
|
||||||
companyFields.companyPhone = toCanonicalPhone(companyFields.companyPhone);
|
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)
|
if (companyFields.areaId !== undefined)
|
||||||
companyFields.areaId = toBackendAreaId(companyFields.areaId);
|
companyFields.areaId = toBackendAreaId(companyFields.areaId);
|
||||||
payload.companyFields = companyFields;
|
payload.companyFields = companyFields;
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,10 @@ const baseCompanyFields = {
|
||||||
companyContact: z.never().optional(),
|
companyContact: z.never().optional(),
|
||||||
companyPhone: northAmericanPhone,
|
companyPhone: northAmericanPhone,
|
||||||
email: optionalEmail,
|
email: optionalEmail,
|
||||||
// SH-271: the single combined Address line became three structured parts,
|
// why: SH-271: the single combined Address line became three structured parts, all required, and the
|
||||||
// all required, and the stored Google Maps URL was retired from the form.
|
// stored Google Maps URL was retired from the form. `googleMapsUrl` stays on the read models for
|
||||||
// `googleMapsUrl` stays on the read models for legacy records but is not a
|
// legacy records but is not a form value: loading it would validate and re-emit a deprecated field
|
||||||
// form value: loading it would validate and re-emit a deprecated field the
|
// the user can no longer see or edit.
|
||||||
// user can no longer see or edit.
|
|
||||||
address: z.string().trim().min(1, "Street Address is required"),
|
address: z.string().trim().min(1, "Street Address is required"),
|
||||||
city: z.string().trim().min(1, "City is required"),
|
city: z.string().trim().min(1, "City is required"),
|
||||||
state: z.string().trim().min(1, "State is required"),
|
state: z.string().trim().min(1, "State is required"),
|
||||||
|
|
@ -54,7 +53,7 @@ const baseCompanyFields = {
|
||||||
notes: z
|
notes: z
|
||||||
.string()
|
.string()
|
||||||
.max(VENDOR_NOTES_MAX_LENGTH, `Notes must be ${VENDOR_NOTES_MAX_LENGTH} characters or fewer`),
|
.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(),
|
areaId: z.string(),
|
||||||
technicians: z.array(rosterTechnicianSchema),
|
technicians: z.array(rosterTechnicianSchema),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,8 @@ export interface SaveVendorCompanyRosterContext {
|
||||||
conflict?: unknown;
|
conflict?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `googleMapsUrl` is deliberately absent: it is a deprecated read-only field,
|
// why: `googleMapsUrl` is deliberately absent: it is a deprecated read-only field, so neither the
|
||||||
// so neither the additive companyFields patch nor the status-only diff may
|
// additive companyFields patch nor the status-only diff may ever transmit it.
|
||||||
// ever transmit it.
|
|
||||||
const COMPANY_FIELDS = [
|
const COMPANY_FIELDS = [
|
||||||
"name",
|
"name",
|
||||||
"companyPhone",
|
"companyPhone",
|
||||||
|
|
|
||||||
|
|
@ -216,10 +216,10 @@ export const workOrderBoardDocumentsApi = {
|
||||||
workOrderId: string | number,
|
workOrderId: string | number,
|
||||||
mediaId: string | number,
|
mediaId: string | number,
|
||||||
): Promise<Blob> => {
|
): Promise<Blob> => {
|
||||||
// No `credentials: "include"`: this endpoint authenticates with the bearer token the ky
|
// 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
|
// beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the API's
|
||||||
// API's `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document
|
// `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document would never
|
||||||
// would never open.
|
// open.
|
||||||
const response = await apiRequestRaw(
|
const response = await apiRequestRaw(
|
||||||
"get",
|
"get",
|
||||||
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
|
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { API_PATHS } from "@/api/api-paths";
|
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 { handleApiResponse } from "@/api/handle-api-response";
|
||||||
import {
|
import {
|
||||||
mapWorkOrderUplift,
|
mapWorkOrderUplift,
|
||||||
|
|
@ -19,6 +19,22 @@ function extractUpliftItems(data: unknown): unknown[] {
|
||||||
return Array.isArray(items) ? items : [];
|
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 = {
|
export const workOrderUpliftsApi = {
|
||||||
list: async (workOrderId: string | number): Promise<WorkOrderUplift[]> => {
|
list: async (workOrderId: string | number): Promise<WorkOrderUplift[]> => {
|
||||||
const data = await apiGet<unknown>(API_PATHS.workOrder.uplifts(workOrderId));
|
const data = await apiGet<unknown>(API_PATHS.workOrder.uplifts(workOrderId));
|
||||||
|
|
@ -30,13 +46,45 @@ export const workOrderUpliftsApi = {
|
||||||
workOrderId: string | number,
|
workOrderId: string | number,
|
||||||
input: CreateWorkOrderUpliftInput,
|
input: CreateWorkOrderUpliftInput,
|
||||||
): Promise<WorkOrderUplift> => {
|
): 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,
|
amount: input.amount,
|
||||||
notes: input.notes,
|
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));
|
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 (
|
cancel: async (
|
||||||
workOrderId: string | number,
|
workOrderId: string | number,
|
||||||
upliftId: 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.",
|
"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({
|
const result = await workOrdersApi.searchBoard({
|
||||||
search: normalized,
|
search: normalized,
|
||||||
datePreset: "Custom",
|
datePreset: "Custom",
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,8 @@ function expandPocOp(patch: WorkOrderTablePatch): BoardPocOp[] {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// why: Schedule and week fields must go out first, in a stable order, then the composite POC op,
|
||||||
* Expands a UI table patch into ordered board ops (one request each).
|
// then scalar fields — each as its own board request.
|
||||||
* Schedule/week-only fields are emitted first in a stable order; the composite
|
|
||||||
* POC op follows, then scalar fields.
|
|
||||||
*/
|
|
||||||
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
|
export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): BoardOp[] {
|
||||||
if (isLocalOnlyTablePatch(patch)) {
|
if (isLocalOnlyTablePatch(patch)) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
UpliftSummary,
|
UpliftSummary,
|
||||||
WorkOrderUplift,
|
WorkOrderUplift,
|
||||||
|
WorkOrderUpliftAttachment,
|
||||||
WorkOrderUpliftStatus,
|
WorkOrderUpliftStatus,
|
||||||
} from "@/domain/work-orders/types/work-order-uplift";
|
} from "@/domain/work-orders/types/work-order-uplift";
|
||||||
import { WORK_ORDER_UPLIFT_STATUSES } 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;
|
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 {
|
export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
const statusRaw = readString(item, "status", "Status");
|
const statusRaw = readString(item, "status", "Status");
|
||||||
|
|
@ -149,7 +197,8 @@ export function mapWorkOrderUplift(raw: unknown): WorkOrderUplift {
|
||||||
),
|
),
|
||||||
decidedAt: readString(item, "decidedAt", "DecidedAt"),
|
decidedAt: readString(item, "decidedAt", "DecidedAt"),
|
||||||
decidedByName: readString(item, "decidedByName", "DecidedByName"),
|
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;
|
amount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderUpliftAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkOrderUplift {
|
export interface WorkOrderUplift {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
status: WorkOrderUpliftStatus;
|
status: WorkOrderUpliftStatus;
|
||||||
|
|
@ -27,12 +33,15 @@ export interface WorkOrderUplift {
|
||||||
decidedAt: string;
|
decidedAt: string;
|
||||||
decidedByName: string;
|
decidedByName: string;
|
||||||
decisionNote: string;
|
decisionNote: string;
|
||||||
|
attachments?: WorkOrderUpliftAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateWorkOrderUpliftInput {
|
export interface CreateWorkOrderUpliftInput {
|
||||||
amount: number;
|
amount: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
attachments?: File[];
|
files?: File[];
|
||||||
|
evidenceDocumentId?: number;
|
||||||
|
evidenceDocumentIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_UPLIFT_SUMMARY: UpliftSummary = {
|
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 { toast } from "react-toastify";
|
||||||
import { mapHttpStatusToMessage, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error";
|
import { mapHttpStatusToMessage, REQUEST_UPLIFTS_ROLE_DENIAL_MESSAGE } from "@/api/api-error";
|
||||||
import { workOrderUpliftsApi } from "@/domain/work-orders/api/work-order-uplifts-api";
|
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 {
|
import type {
|
||||||
CreateWorkOrderUpliftInput,
|
CreateWorkOrderUpliftInput,
|
||||||
WorkOrderUplift,
|
WorkOrderUplift,
|
||||||
|
|
@ -63,7 +64,12 @@ export function useCreateWorkOrderUplift(
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (input: CreateWorkOrderUpliftInput) =>
|
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: () => {
|
onSuccess: () => {
|
||||||
invalidateUpliftQueries(queryClient, workOrderId);
|
invalidateUpliftQueries(queryClient, workOrderId);
|
||||||
toast.success("Uplift request created");
|
toast.success("Uplift request created");
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,6 @@ type AdvancedFilterPredicate = (
|
||||||
const isOpenUndated = (row: WorkOrderTableRow): boolean =>
|
const isOpenUndated = (row: WorkOrderTableRow): boolean =>
|
||||||
!row.scheduledOn && !isTerminalCarryOverStatus(row.status);
|
!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) => {
|
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
||||||
if (filters.unscheduledOnly) {
|
if (filters.unscheduledOnly) {
|
||||||
return isOpenUndated(row);
|
return isOpenUndated(row);
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,6 @@ function mapSearchFacetParams(
|
||||||
|
|
||||||
/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
|
/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */
|
||||||
function mapWorkOrderIds(ids: Set<number> | undefined): string | undefined {
|
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) : [];
|
const normalized = ids?.size ? normalizeWorkOrderIds(ids) : [];
|
||||||
return normalized.length > 0 ? normalized.join(",") : undefined;
|
return normalized.length > 0 ? normalized.join(",") : undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -151,13 +150,11 @@ function resolveAdvancedSearchDate(filters: WorkOrderAdvancedFilters): {
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
includeDateless?: boolean;
|
includeDateless?: boolean;
|
||||||
} {
|
} {
|
||||||
// SH-338: server-side unscheduledOnly owns the date-range exemption; the
|
// why: SH-338: server-side unscheduledOnly owns the date-range exemption; the client must not fake a
|
||||||
// client must not fake a Custom window and sends no date params.
|
// Custom window and sends no date params.
|
||||||
if (filters.unscheduledOnly) {
|
if (filters.unscheduledOnly) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
// No range selected searches every week, undated rows included. Any
|
|
||||||
// selected range (This week included) strictly narrows the results.
|
|
||||||
if (filters.dateRange === null) {
|
if (filters.dateRange === null) {
|
||||||
return {
|
return {
|
||||||
datePreset: "Custom",
|
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
|
* 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.
|
* 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 {
|
function parseUtc(value: string): number {
|
||||||
return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`);
|
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)) {
|
if (Number.isNaN(createdAt) || createdAt < since || isOwnWorkOrderComment(comment, userId)) {
|
||||||
continue;
|
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.
|
// `mentions` id array alongside plain `@Name` text. Treat either as a mention of the user.
|
||||||
const mentionIds = new Set(extractMentionIds(comment.commenttext ?? ""));
|
const mentionIds = new Set(extractMentionIds(comment.commenttext ?? ""));
|
||||||
for (const id of comment.mentions ?? []) {
|
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"];
|
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 BOARD_REGION_NAMES = ["East", "Central", "West", "California", "Unmapped/Other"];
|
||||||
|
|
||||||
const UPLIFT_FILTER_VALUES: UpliftFilterStatus[] = [
|
const UPLIFT_FILTER_VALUES: UpliftFilterStatus[] = [
|
||||||
|
|
@ -76,7 +76,6 @@ export function buildSiteOpenWorkOrdersHref(siteId: string | number): string {
|
||||||
export function parseDashboardDrilldownParams(
|
export function parseDashboardDrilldownParams(
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
): WorkOrderAdvancedFilters | null {
|
): 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"));
|
const workOrderIds = parseWorkOrderIdsParam(params.get("ids"));
|
||||||
if (workOrderIds.length > 0) {
|
if (workOrderIds.length > 0) {
|
||||||
return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) };
|
return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) };
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ function formatGenericAuditFallback(args: {
|
||||||
if (oldValue.text) {
|
if (oldValue.text) {
|
||||||
return `Changed ${label} from ${wrapAuditValue(oldValue)} to "—"`;
|
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}`;
|
if (fieldName && action) return `Updated ${label}`;
|
||||||
return "Updated work order";
|
return "Updated work order";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,9 @@ import type {
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
/**
|
// why: Primary POC scalars persist on the dedicated POC endpoint, so they stay out of this patch.
|
||||||
* Fields compared for dirty state and included in Save patch.
|
// Tech phone is not board-editable; including it would let Save no-op. additionalContacts stays
|
||||||
* Only keys that map to board PATCH (or required companions like dispatcherName).
|
// because it is a board PATCH field (SH-218).
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
export const SLIDE_OVER_EDIT_KEYS = [
|
export const SLIDE_OVER_EDIT_KEYS = [
|
||||||
"woNumber",
|
"woNumber",
|
||||||
"site",
|
"site",
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,8 @@ export function computeIsPastDue(row: WorkOrderTableRow): boolean {
|
||||||
if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
|
if (!row.scheduledOn || TERMINAL_STATUSES.has(row.status)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// scheduledOn is a calendar date, so compare it to the user's local calendar
|
// why: scheduledOn is a calendar date, so compare it to the user's local calendar day. A UTC "today"
|
||||||
// day. A UTC "today" rolls over hours early west of UTC and flags work
|
// rolls over hours early west of UTC and flags work scheduled for today as Past Due every evening.
|
||||||
// scheduled for today as Past Due every evening.
|
|
||||||
return row.scheduledOn.slice(0, 10) < todayIso();
|
return row.scheduledOn.slice(0, 10) < todayIso();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,26 @@ export function formatUpliftMoney(value: number | null | undefined): string {
|
||||||
}).format(value);
|
}).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",
|
pending: "Pending",
|
||||||
approved: "Approved",
|
approved: "Approved",
|
||||||
auto_approved: "Auto",
|
auto_approved: "Approved",
|
||||||
rejected: "Rejected",
|
rejected: "Rejected",
|
||||||
cancelled: "Cancelled",
|
cancelled: "Cancelled",
|
||||||
revoked: "Revoked",
|
revoked: "Revoked",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string {
|
export function formatUpliftStatusLabel(status: WorkOrderUpliftStatus): string {
|
||||||
return STATUS_LABELS[status];
|
return PILL_LABELS[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUpliftCellLabel(summary: UpliftSummary): string | null {
|
export function getUpliftCellLabel(summary: UpliftSummary): string | null {
|
||||||
|
|
@ -34,12 +43,15 @@ export function getUpliftCellLabel(summary: UpliftSummary): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (summary.pendingCount > 0) {
|
if (summary.pendingCount > 0) {
|
||||||
|
if (summary.amount != null) {
|
||||||
|
return `${formatUpliftMoney(summary.amount)} Pending`;
|
||||||
|
}
|
||||||
return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`;
|
return summary.pendingCount === 1 ? "1 Pending" : `${summary.pendingCount} Pending`;
|
||||||
}
|
}
|
||||||
if (summary.primaryStatus) {
|
if (summary.primaryStatus) {
|
||||||
const label = formatUpliftStatusLabel(summary.primaryStatus);
|
const label = CELL_STATUS_WORD[summary.primaryStatus];
|
||||||
if (summary.amount != null) {
|
if (summary.amount != null) {
|
||||||
return `${label} ${formatUpliftMoney(summary.amount)}`;
|
return `${formatUpliftMoney(summary.amount)} ${label}`;
|
||||||
}
|
}
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
@ -57,14 +69,13 @@ export function getUpliftStatusPillStyle(status: WorkOrderUpliftStatus): {
|
||||||
case "pending":
|
case "pending":
|
||||||
return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" };
|
return { background: "var(--status-pending-bg)", color: "var(--status-pending-text)" };
|
||||||
case "approved":
|
case "approved":
|
||||||
return { background: "var(--status-success-bg, #DCFCE7)", color: "var(--color-success)" };
|
|
||||||
case "auto_approved":
|
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":
|
case "rejected":
|
||||||
return { background: "#FEE2E2", color: "#B91C1C" };
|
return { background: "var(--status-pastdue-bg)", color: "var(--status-pastdue-text)" };
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
case "revoked":
|
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:
|
default:
|
||||||
return { background: "var(--color-bg-muted)", color: "var(--color-text-muted)" };
|
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";
|
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(
|
export function canRevokeWorkOrderUplift(
|
||||||
uplift: Pick<WorkOrderUplift, "status" | "requestedById">,
|
uplift: Pick<WorkOrderUplift, "status" | "requestedById">,
|
||||||
currentUserId: string | number | null | undefined,
|
currentUserId: string | number | null | undefined,
|
||||||
currentUserIsAdmin: boolean,
|
currentUserIsAdmin: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (uplift.status !== "auto_approved") {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (currentUserIsAdmin) {
|
if (currentUserIsAdmin) {
|
||||||
// Admins revoke only admin-approved uplifts; an auto-approval is the requester's to revoke.
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|
@ -123,3 +180,90 @@ export function getRemainingUpliftAllowance(
|
||||||
.reduce((sum, uplift) => sum + uplift.amount, 0);
|
.reduce((sum, uplift) => sum + uplift.amount, 0);
|
||||||
return Math.max(0, getUpliftAllowanceCap(workOrderType) - consumed);
|
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)) {
|
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
|
||||||
return { fileType: "pdf", category: "Report" };
|
return { fileType: "pdf", category: "Report" };
|
||||||
}
|
}
|
||||||
// Phones often send an empty or octet-stream type; fall back to the extension.
|
|
||||||
const kind = resolveWorkOrderUploadKind(f);
|
const kind = resolveWorkOrderUploadKind(f);
|
||||||
if (kind === "photo") return { fileType: "image", category: "Photo" };
|
if (kind === "photo") return { fileType: "image", category: "Photo" };
|
||||||
if (kind === "video") return { fileType: "video", category: "Video" };
|
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