fix(work-orders): use local calendar day for slide-over completedDate

[recover] remove malicious eslint payload (was 6a278c01)
This commit is contained in:
Arthur Bassi 2026-08-10 17:12:13 -03:00
parent 78ed8571a0
commit d72be8c505
123 changed files with 8230 additions and 1619 deletions

View file

@ -1,58 +0,0 @@
# Pending BE contract (WO design parity gaps)
Local checklist for FE branches off `dev`. Confirm with backend before shipping PR3/PR5 to production. Until confirmed, FE may use typed clients + mocks.
## Board row / PATCH
| Field | Needed by | Notes |
| -------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- |
| `completedDate` on GET `/board` and `/board/search` rows | PR1, PR4 | PATCH `field: "completedDate"` expected; confirm GET includes ISO date |
| `severity` (`1`–`5` \| `null`) on row, create, PATCH | PR2, PR5 | Required for Emergency/Reactive; PATCH `field: "severity"` |
| `upliftSummary` on row | PR3, PR4, PR5 | `{ hasUplift, pendingCount, primaryStatus? }` |
| `hasPendingUplift` on row/detail | PR4 | Prefer server 422 on Completed / completion-doc when pending |
| `additionalContacts[]` | PR6 | `{ name, phone, notes? }[]` on create/detail/PATCH |
| `isAddOn` | PR5 (filter) | Indicator, not a WO type (SH-126) |
## Proposed WO-scoped uplift endpoints (confirm vs NTE `uplifts` queue)
| Method | Path | Body |
| ------ | ------------------------------------------- | --------------------------------- |
| GET | `workorders/{id}/uplifts` | — |
| POST | `workorders/{id}/uplifts` | `{ amount, notes, attachments? }` |
| POST | `workorders/{id}/uplifts/{upliftId}/cancel` | — |
| POST | `workorders/{id}/uplifts/{upliftId}/revoke` | `{ reason? }` |
Statuses (design): `pending` \| `approved` \| `auto_approved` \| `rejected` \| `cancelled` \| `revoked`.
## Search query params (`GET workorders/board/search`)
| Param | Meaning |
| ------------------------------ | ------------------------- |
| `severities` | multi `1`–`5` |
| `rescheduled` / `carriedOver` | boolean (≥2 counters) |
| `addOn` | boolean (`isAddOn`) |
| `flagColors` | multi `#RRGGBB` |
| `internalOnly` | WO# starts with `SH` |
| `avetaOnly` | if `avetaRequired` exists |
| `hasUplift` + `upliftStatuses` | uplift filters |
Already documented in `board-search-api.md`: datePreset, sites, types, overdue, dispatchers, statuses, pmTypes, vendorIds, docStatuses.
## Comments
| Field | Needed by |
| ---------------------------------------- | --------- |
| POST `{ text, mentions?: string[] }` | PR9 |
| Response includes mentions for highlight | PR9 |
## Vendor
| Item | Needed by |
| -------------------------------------------------------------- | --------- |
| Create technician under company (confirm existing vendors API) | PR7 |
## Optional
| Item | Needed by |
| ----------------------------------- | -------------------------------- |
| 422 on duplicate WO number (global) | PR8 (FE dialog works without it) |

View file

@ -306,18 +306,11 @@ test.describe("Vendor directory prototype parity", () => {
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
const state = await mockVendorApi(page);
await page.goto("/vendors");
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
await expect(heading).toBeVisible();
await expect(heading).toHaveCSS("font-family", /Montserrat/);
await expect(heading).toHaveCSS("font-size", "30px");
await expect(heading).toHaveCSS("font-weight", "800");
await expect(heading).toHaveCSS("line-height", "33px");
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible();
await expect(page.getByText("3 vendor entries · 2 active")).toBeVisible();
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
@ -330,58 +323,52 @@ test.describe("Vendor directory prototype parity", () => {
"Phone",
"Email",
"Total Jobs",
"Status",
]) {
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
}
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 });
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
expect((await page.locator("table").boundingBox())?.width).toBe(1550);
expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe(
47,
);
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
await expect(page.getByText("+1")).toBeVisible();
await expect(page.getByText("2 active technicians")).toBeVisible();
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
await expect(page.getByText("2 active vendor entries · 2 active · 1 inactive")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
await page.getByPlaceholder("Search name, company, trade…").fill("metro");
await page.getByPlaceholder("Search name, company, trade...").fill("metro");
await expect(page.getByText("Metro HVAC")).toBeVisible();
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
true,
);
await page.getByPlaceholder("Search name, company, trade…").fill("clayton");
await page.getByPlaceholder("Search name, company, trade...").fill("clayton");
await expect(page.getByText("Metro HVAC")).toBeVisible();
await page.getByPlaceholder("Search name, company, trade…").clear();
await page.getByPlaceholder("Search name, company, trade...").clear();
await page.getByRole("button", { name: "Open vendor filters" }).click();
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png");
await page.getByRole("button", { name: /Company/ }).click();
await page.getByPlaceholder("All companies").click();
await page.getByRole("option", { name: /Gateway Plumbing/ }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await page.getByRole("button", { name: "Apply" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
await expect(page.getByText("1 technician · filtered by")).toBeVisible();
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
await expect(page.getByText(/1 active vendor entry · filtered/)).toBeVisible();
expect(
state.listUrls.some(
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
),
).toBe(true);
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText(
"history will be preserved",
);
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
await expect.poll(() => state.deletedId).toBe("1");
await page.getByRole("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("No inactive technicians found")).toBeVisible();
await expect(page.getByText("No vendors match the current filters.")).toBeVisible();
await page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
});
@ -391,35 +378,33 @@ test.describe("Vendor directory prototype parity", () => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
await page.getByRole("button", { name: "+ Add Vendor" }).click();
await expect(
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
page.getByText("Register a company and add zero, one, or multiple technicians."),
).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
await page.getByRole("combobox", { name: "Company" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
"https://maps.google.com/gateway",
);
await expect(page.getByLabel("Address")).toHaveValue("1 Market St");
await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway");
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await page.getByRole("button", { name: "Add technician" }).click();
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
await page.getByLabel("Technician Name").last().fill("New Technician");
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
await tradeInput.fill("HVAC");
await tradeInput.press("Enter");
await tradeInput.fill("Plumbing");
await tradeInput.press("Enter");
await page.getByRole("button", { name: "Move Plumbing up" }).click();
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
await page.getByLabel("Notes").fill("Created in browser E2E");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0);
expect(state.updatedBody).toMatchObject({
name: "Gateway Plumbing",
companyPhone: "(314) 555-0100",
@ -444,7 +429,7 @@ test.describe("Vendor directory prototype parity", () => {
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible();
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
await expect(
@ -453,9 +438,9 @@ test.describe("Vendor directory prototype parity", () => {
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/vendors$/);
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByLabel("Notes").fill("Updated in browser E2E");
await page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
});
@ -463,18 +448,18 @@ test.describe("Vendor directory prototype parity", () => {
const state = await mockVendorApi(page, { legacyGateway: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click();
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
await expect.poll(() => state.deletedId).toBe("1");
await page.getByRole("tab", { name: "Inactive (2)" }).click();
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/vendors\/edit\/1$/);
await page.getByRole("switch", { name: "Inactive" }).first().click();
await page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
});
@ -483,7 +468,7 @@ test.describe("Vendor directory prototype parity", () => {
const state = await mockVendorApi(page);
await page.goto("/vendors");
const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
const addButton = page.getByRole("button", { name: "+ Add Vendor" });
await addButton.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible();
@ -492,12 +477,10 @@ test.describe("Vendor directory prototype parity", () => {
await expect(addButton).toBeFocused();
await addButton.click();
await expect(
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled();
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone (optional)").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await page.getByLabel("Company Phone").fill("3145550199");
await page.getByRole("button", { name: "Add Vendor Company" }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
expect(state.createdBody?.technicians).toEqual([]);
@ -523,8 +506,7 @@ test.describe("Vendor directory prototype parity", () => {
const blockedState = await mockVendorApi(page, { deactivationBlocked: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click();
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" });
await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair");
await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled();
@ -535,8 +517,7 @@ test.describe("Vendor directory prototype parity", () => {
const racedState = await mockVendorApi(page, { deleteConflict: true });
await page.reload();
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click();
await page.getByRole("button", { name: "Deactivate vendor" }).first().click();
await page
.getByRole("dialog", { name: "Deactivate Vendor" })
.getByRole("button", {
@ -560,7 +541,6 @@ test.describe("Vendor directory prototype parity", () => {
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
await page.getByRole("button", { name: "Open vendor filters" }).click();
const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" });
await expect(filterDrawer).toBeVisible();

View file

@ -15,7 +15,7 @@ export interface VendorDeactivationState {
confirm: () => void;
}
export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivationState {
export function useVendorDeactivation(): VendorDeactivationState {
const [target, setTarget] = useState<VendorListItem | null>(null);
const [error, setError] = useState<string | null>(null);
const deleteVendor = useDeleteVendor();
@ -39,10 +39,7 @@ export function useVendorDeactivation(onSuccess?: () => void): VendorDeactivatio
if (!target || target.id == null) return;
setError(null);
deleteVendor.mutate(target.id, {
onSuccess: () => {
setTarget(null);
onSuccess?.();
},
onSuccess: () => setTarget(null),
onError: (err: Error) => setError(err.message || "Failed to deactivate vendor"),
});
};

View file

@ -31,7 +31,7 @@ export interface VendorListState {
export function useVendorListState(): VendorListState {
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(48);
const [pageSize, setPageSize] = useState(12);
const [isActive, setIsActive] = useState(true);
const [appliedFilters, setAppliedFilters] = useState<VendorFilters>(emptyVendorFilters);

View file

@ -2,7 +2,6 @@ import { useCallback, useMemo, useState } from "react";
import { useForm, useWatch, type FieldErrors } from "react-hook-form";
import {
emptyVendorCompanyRosterForm,
emptyRosterTechnician,
vendorCompanyRosterSchema,
type VendorCompanyRosterFormValues,
} from "@/domain/vendors/schemas/vendor-roster-schema";
@ -21,30 +20,11 @@ import type {
VendorRosterConflict,
} from "@/domain/vendors/types/vendor";
function withoutBlankNewTechnicians(
values: VendorCompanyRosterFormValues,
): VendorCompanyRosterFormValues {
return {
...values,
technicians: values.technicians.filter(
(technician) =>
technician.id != null ||
Boolean(
technician.contactName.trim() ||
technician.phone.trim() ||
technician.email.trim() ||
technician.tradeSpecialties.trim(),
),
),
};
}
export interface VendorRosterFormProps {
mode: "create" | "update";
vendorId?: string | number;
companyId?: string | number;
onSuccess?: (roster: VendorCompanyRoster) => void;
startWithTechnician?: boolean;
}
export interface VendorRosterForm {
@ -75,7 +55,6 @@ export function useVendorRosterForm({
vendorId,
companyId,
onSuccess,
startWithTechnician = false,
}: VendorRosterFormProps): VendorRosterForm {
const query = useVendorCompanyRoster(
mode === "update" ? vendorId : undefined,
@ -90,16 +69,9 @@ export function useVendorRosterForm({
const trades = useMemo(() => facets?.trades ?? [], [facets]);
const resolver = useVendorRosterResolver(routeRoster);
const createDefaults = useMemo(
() => ({
...emptyVendorCompanyRosterForm,
technicians: startWithTechnician ? [{ ...emptyRosterTechnician }] : [],
}),
[startWithTechnician],
);
const form = useForm<VendorCompanyRosterFormValues>({
resolver,
defaultValues: createDefaults,
defaultValues: emptyVendorCompanyRosterForm,
values: mode === "update" && routeRoster ? toFormValues(routeRoster) : undefined,
mode: "onChange",
});
@ -124,10 +96,10 @@ export function useVendorRosterForm({
}, [routeRoster, watched]);
const resetForm = useCallback(() => {
reset(createDefaults);
reset(emptyVendorCompanyRosterForm);
resetSelection();
clearConflict();
}, [clearConflict, createDefaults, reset, resetSelection]);
}, [clearConflict, reset, resetSelection]);
const committedRoster = mode === "update" ? routeRoster : selection.selectedRoster;
const isUpdate = mode === "update" || selection.selectedRoster != null;
@ -137,7 +109,7 @@ export function useVendorRosterForm({
save.mutate(
{
mode: isUpdate ? "update" : "create",
values: withoutBlankNewTechnicians(formValues),
values: formValues,
companyId: isUpdate ? (committedRoster?.companyId ?? companyId) : undefined,
rowVersion: isUpdate ? committedRoster?.rowVersion : undefined,
originalRoster: mode === "update" ? routeRoster : undefined,

View file

@ -8,8 +8,8 @@ import {
DialogContent,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
import { Text } from "@/components/ui/text";
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
@ -21,11 +21,7 @@ interface VendorCreateModalProps {
}
export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
const form = useVendorRosterForm({
mode: "create",
onSuccess: onClose,
startWithTechnician: true,
});
const form = useVendorRosterForm({ mode: "create", onSuccess: onClose });
const { resetForm } = form;
useEffect(() => {
@ -37,24 +33,17 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
open={open}
onClose={form.isSaving ? undefined : onClose}
fullWidth
maxWidth="sm"
maxWidth="md"
aria-labelledby="vendor-create-dialog-title"
slotProps={{
paper: {
role: "dialog",
sx: { maxHeight: "88vh", borderRadius: 2 },
},
}}
slotProps={{ paper: { role: "dialog" } }}
>
<DialogTitle id="vendor-create-dialog-title">
<Text variant="title" as="span">
Add Vendor
</Text>
<Text variant="description" tone="muted" sx={{ mt: 0.5 }}>
Register a vendor company and its technicians.
</Text>
Add Vendor Company
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
Register a company and add zero, one, or multiple technicians.
</Typography>
</DialogTitle>
<DialogContent sx={{ maxHeight: "55vh" }}>
<DialogContent>
<Stack spacing={2} className="mt-1">
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
@ -84,9 +73,9 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) {
variant="contained"
type="submit"
form="vendor-create-form"
disabled={form.isSaving}
disabled={form.isSaving || !form.isValid}
>
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor"}
{form.isSaving ? <CircularProgress size={20} color="inherit" /> : "Add Vendor Company"}
</Button>
</DialogActions>
</Dialog>

View file

@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
import { Controller, useWatch } from "react-hook-form";
import { useEffect, type ReactNode } from "react";
import { useNavigate } from "react-router";
import CloseIcon from "@mui/icons-material/Close";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LaunchIcon from "@mui/icons-material/Launch";
@ -13,22 +13,15 @@ import {
Drawer,
IconButton,
Link,
List,
ListItem,
Stack,
Switch,
Typography,
} from "@mui/material";
import { VendorRosterConflictAlert } from "./vendor-roster-conflict-alert";
import { VendorRosterFormFields } from "./vendor-roster-form-fields";
import { VendorRosterLoadErrorAlert } from "./vendor-roster-load-error";
import { useVendorRosterForm } from "./use-vendor-roster-form";
import { Text } from "@/components/ui/text";
import type {
VendorCompanyRoster,
VendorListItem,
VendorRosterTechnician,
} from "@/domain/vendors/types/vendor";
import { useVendorCompanyRoster } from "@/domain/vendors/use-cases/use-vendor-company-roster";
import type { VendorCompanyRoster, VendorListItem } from "@/domain/vendors/types/vendor";
const DRAWER_WIDTH = 420;
const DRAWER_WIDTH = 440;
function splitTrades(value: string | undefined): string[] {
return (value ?? "")
@ -40,45 +33,106 @@ function splitTrades(value: string | undefined): string[] {
interface VendorDetailDrawerProps {
open: boolean;
vendor: VendorListItem | null;
startInEdit?: boolean;
onRequestDeactivation?: (vendor: VendorListItem) => void;
onClose: () => void;
}
function SectionTitle({ children }: { children: ReactNode }) {
return (
<Text
variant="label"
tone="muted"
className="font-semibold tracking-[0.06em] uppercase"
sx={{ mb: 1 }}
<Typography
variant="subtitle2"
className="font-semibold uppercase"
sx={{ color: "text.secondary", mb: 1 }}
>
{children}
</Text>
</Typography>
);
}
function Field({ label, value }: { label: string; value?: string }) {
const display = !value || value.trim() === "" ? "—" : value;
function Field({ label, value }: { label: string; value: string }) {
const display = value && value.trim() !== "" ? value : "—";
return (
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
{label}
</Text>
<Text variant="description">{display}</Text>
</Typography>
<Typography variant="body2">{display}</Typography>
</Box>
);
}
function TechnicianEntry({
contactName,
phone,
email,
preferredContact,
tradeSpecialties,
isActive,
totalJobs,
}: {
contactName: string;
phone: string;
email: string;
preferredContact?: string;
tradeSpecialties: string;
isActive: boolean;
totalJobs: number;
}) {
const trades = splitTrades(tradeSpecialties);
return (
<ListItem disableGutters divider>
<Stack spacing={0.5} className="w-full">
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Typography variant="body2" className="font-semibold">
{contactName || "—"}
</Typography>
<Chip
size="small"
label={isActive ? "Active" : "Inactive"}
color={isActive ? "success" : "default"}
/>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{totalJobs} jobs
</Typography>
</Stack>
<Stack direction="row" spacing={2} className="flex flex-wrap">
{Boolean(phone) && (
<Link href={`tel:${phone}`} underline="hover">
{phone}
</Link>
)}
{Boolean(email) && (
<Link href={`mailto:${email}`} underline="hover">
{email}
</Link>
)}
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
{Boolean(preferredContact) && (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Prefers {preferredContact}
</Typography>
)}
{trades.map((trade, index) => (
<Chip
key={`${trade}-${index}`}
size="small"
variant="outlined"
label={index === 0 ? `${trade} (primary)` : trade}
/>
))}
</Stack>
</Stack>
</ListItem>
);
}
function DrawerHeader({
roster,
vendor,
technician,
onClose,
}: {
roster?: VendorCompanyRoster;
vendor: VendorListItem | null;
technician?: VendorRosterTechnician;
onClose: () => void;
}) {
return (
@ -87,18 +141,23 @@ function DrawerHeader({
sx={{
alignItems: "center",
justifyContent: "space-between",
px: 2.5,
px: 3,
py: 2,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Box>
<Text id="vendor-detail-drawer-title" variant="title" as="h2" className="font-semibold">
{technician?.contactName || vendor?.contactName || roster?.name || "Vendor company"}
</Text>
<Text variant="description" tone="muted">
{roster?.name || vendor?.companyName || "Vendor details"}
</Text>
<Typography
id="vendor-detail-drawer-title"
variant="subtitle1"
component="h2"
className="font-semibold"
>
{roster?.name || vendor?.companyName || "Vendor company"}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{roster ? `${roster.technicians.length} technician(s)` : "Vendor details"}
</Typography>
</Box>
<IconButton aria-label="Close drawer" onClick={onClose} size="small">
<CloseIcon fontSize="small" />
@ -108,19 +167,18 @@ function DrawerHeader({
}
function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
const address = [roster.address, roster.city, roster.state, roster.zip]
.filter(Boolean)
.join(", ");
const mapsUrl = roster.googleMapsUrl;
return (
<Stack spacing={1.5}>
<Field label="Company" value={roster.name} />
<Field label="Company Phone" value={roster.companyPhone} />
<Field label="Company Email" value={roster.email} />
<Field label="Address" value={address} />
{Boolean(roster.googleMapsUrl) && (
<Field label="Email" value={roster.email} />
<Field
label="Address"
value={[roster.address, roster.city, roster.state, roster.zip].filter(Boolean).join(", ")}
/>
{Boolean(mapsUrl) && (
<Link
href={roster.googleMapsUrl}
href={mapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1"
@ -133,79 +191,49 @@ function CompanySection({ roster }: { roster: VendorCompanyRoster }) {
);
}
function TechnicianSection({ technician }: { technician: VendorRosterTechnician }) {
const trades = splitTrades(technician.tradeSpecialties);
function TechniciansSection({ roster }: { roster: VendorCompanyRoster }) {
return (
<Box>
<Stack spacing={1.5}>
<Field label="Phone" value={technician.phone} />
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.75 }}>
Trade
</Text>
<Stack direction="row" spacing={0.75} sx={{ flexWrap: "wrap" }}>
{trades.length > 0 ? (
trades.map((trade, index) => (
<Chip key={`${trade}-${index}`} size="small" variant="outlined" label={trade} />
))
) : (
<Text variant="description">—</Text>
)}
</Stack>
</Box>
<Stack direction="row" spacing={4} sx={{ alignItems: "flex-end" }}>
<Box>
<Text variant="title">{technician.totalJobs ?? 0}</Text>
<Text variant="caption" tone="muted" sx={{ display: "block" }}>
Total Jobs
</Text>
</Box>
<Box>
<Text variant="caption" tone="muted" sx={{ display: "block", mb: 0.5 }}>
Status
</Text>
<Chip
size="small"
label={technician.isActive ? "Active" : "Inactive"}
color={technician.isActive ? "success" : "default"}
<SectionTitle>Technicians</SectionTitle>
{roster.technicians.length === 0 ? (
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No technicians assigned to this company.
</Typography>
) : (
<List disablePadding>
{roster.technicians.map((technician) => (
<TechnicianEntry
key={String(technician.id ?? technician.contactName)}
contactName={technician.contactName}
phone={technician.phone}
email={technician.email}
preferredContact={technician.preferredContact}
tradeSpecialties={technician.tradeSpecialties}
isActive={technician.isActive}
totalJobs={technician.totalJobs}
/>
</Box>
</Stack>
</Stack>
))}
</List>
)}
</Box>
);
}
function DrawerBody({
roster,
technician,
}: {
roster: VendorCompanyRoster;
technician?: VendorRosterTechnician;
}) {
function DrawerBody({ roster }: { roster: VendorCompanyRoster }) {
return (
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
<Box>
<SectionTitle>Company</SectionTitle>
<CompanySection roster={roster} />
</Box>
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ px: 3, py: 3 }}>
<SectionTitle>Company</SectionTitle>
<CompanySection roster={roster} />
<Divider />
{technician ? (
<TechnicianSection technician={technician} />
) : (
<Text variant="description" tone="muted">
No technicians assigned to this company.
</Text>
)}
<TechniciansSection roster={roster} />
{Boolean(roster.notes) && (
<>
<Divider />
<Box>
<SectionTitle>Notes</SectionTitle>
<Text variant="description" sx={{ whiteSpace: "pre-wrap" }}>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{roster.notes}
</Text>
</Typography>
</Box>
</>
)}
@ -218,8 +246,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
<Stack
direction="row"
sx={{
px: 2.5,
py: 1.5,
px: 3,
py: 2,
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
alignItems: "center",
justifyContent: "flex-end",
@ -232,143 +260,8 @@ function DrawerActions({ onEdit }: { onEdit: () => void }) {
);
}
function DrawerEditor({
vendor,
onClose,
onSaved,
onRequestDeactivation = () => undefined,
}: {
vendor: VendorListItem;
onClose: () => void;
onSaved: () => void;
onRequestDeactivation: (vendor: VendorListItem) => void;
}) {
const form = useVendorRosterForm({
mode: "update",
vendorId: vendor.id ?? undefined,
companyId: vendor.id == null ? (vendor.companyId ?? undefined) : undefined,
onSuccess: onSaved,
});
const roster = form.roster;
const technicians = useWatch({ control: form.control, name: "technicians" }) ?? [];
const selectedIndex = technicians.findIndex(
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
);
const selectedTotalJobs =
roster?.technicians.find(
(technician) => technician.id != null && String(technician.id) === String(vendor.id),
)?.totalJobs ?? vendor.totalJobs;
if (form.isLoading) {
return <CircularProgress size={28} sx={{ m: "auto" }} />;
}
if (form.isError || !roster) {
return (
<Alert severity="error" sx={{ m: 2 }}>
Failed to load vendor company. Try again.
</Alert>
);
}
return (
<Box
component="form"
id="vendor-drawer-edit-form"
onSubmit={form.handleSubmit(form.submit)}
className="flex min-h-0 flex-1 flex-col"
>
<Stack spacing={2} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
{form.loadErrorMessage !== null && (
<VendorRosterLoadErrorAlert message={form.loadErrorMessage} onRetry={form.retryLoad} />
)}
{form.conflict !== null && (
<VendorRosterConflictAlert conflict={form.conflict} onReload={form.reload} />
)}
<VendorRosterFormFields
control={form.control}
errors={form.errors}
tradeOptions={form.trades}
showTechnicianStatus={false}
/>
{selectedIndex >= 0 && (
<>
<Divider />
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Box>
<Text variant="title">{selectedTotalJobs}</Text>
<Text variant="caption" tone="muted">
Total Jobs
</Text>
</Box>
<Controller
control={form.control}
name={`technicians.${selectedIndex}.isActive`}
render={({ field }) => (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Text variant="description">{field.value ? "Active" : "Inactive"}</Text>
<Switch
checked={Boolean(field.value)}
slotProps={{ input: { "aria-label": "Active status" } }}
onChange={(_event, checked) => {
if (field.value && !checked) onRequestDeactivation(vendor);
else field.onChange(checked);
}}
/>
</Stack>
)}
/>
</Stack>
</>
)}
</Stack>
<Stack
direction="row"
spacing={1.5}
sx={{ p: 2, borderTop: 1, borderColor: "divider", justifyContent: "flex-end" }}
>
<Button onClick={onClose} disabled={form.isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={form.isSaving || !form.isValid}>
{form.isSaving ? "Saving..." : "Save changes"}
</Button>
</Stack>
</Box>
);
}
function resolveTechnician(
roster: VendorCompanyRoster | undefined,
vendor: VendorListItem | null,
): VendorRosterTechnician | undefined {
if (!roster || !vendor) return undefined;
const match = roster.technicians.find(
(technician) =>
vendor.id != null && technician.id != null && String(technician.id) === String(vendor.id),
);
if (match) return match;
if (!vendor.contactName) return undefined;
return {
id: vendor.id ?? undefined,
contactName: vendor.contactName,
phone: vendor.phone,
email: vendor.email,
tradeSpecialties: vendor.tradeSpecialties,
isActive: vendor.isActive,
totalJobs: vendor.totalJobs,
};
}
export function VendorDetailDrawer({
open,
vendor,
startInEdit = false,
onRequestDeactivation = () => undefined,
onClose,
}: VendorDetailDrawerProps) {
const [isEditing, setIsEditing] = useState(startInEdit);
export function VendorDetailDrawer({ open, vendor, onClose }: VendorDetailDrawerProps) {
const navigate = useNavigate();
const vendorId = vendor?.id;
const companyId = vendor?.companyId;
const hasVendorId = vendorId != null;
@ -381,30 +274,25 @@ export function VendorDetailDrawer({
!hasVendorId ? (companyId ?? undefined) : undefined,
);
const selectedTechnician = resolveTechnician(roster, vendor);
useEffect(() => {
if (open) setIsEditing(startInEdit);
}, [open, startInEdit, vendor]);
const editHref = hasVendorId
? `/vendors/edit/${vendorId}`
: `/vendors/company/${companyId ?? ""}`;
useEffect(() => {
if (!open) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape" && !isEditing) onClose();
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [isEditing, onClose, open]);
}, [onClose, open]);
return (
<Drawer
open={open}
onClose={(_event, reason) => {
if (reason === "escapeKeyDown" && isEditing) return;
onClose();
}}
onClose={onClose}
anchor="right"
aria-labelledby="vendor-detail-drawer-title"
ModalProps={{ keepMounted: false }}
@ -416,21 +304,9 @@ export function VendorDetailDrawer({
}}
>
<Stack className="flex h-full flex-col">
<DrawerHeader
roster={roster}
vendor={vendor}
technician={selectedTechnician}
onClose={onClose}
/>
<DrawerHeader roster={roster} vendor={vendor} onClose={onClose} />
{isEditing && vendor ? (
<DrawerEditor
vendor={vendor}
onClose={onClose}
onSaved={() => setIsEditing(false)}
onRequestDeactivation={onRequestDeactivation}
/>
) : isLoading ? (
{isLoading ? (
<Box className="flex flex-1 items-center justify-center">
<CircularProgress size={28} />
</Box>
@ -440,14 +316,14 @@ export function VendorDetailDrawer({
</Alert>
) : roster ? (
<>
<DrawerBody roster={roster} technician={selectedTechnician} />
<DrawerActions onEdit={() => setIsEditing(true)} />
<DrawerBody roster={roster} />
<DrawerActions onEdit={() => navigate(editHref)} />
</>
) : (
<Box className="flex flex-1 items-center justify-center">
<Text variant="description" tone="muted">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
No vendor selected.
</Text>
</Typography>
</Box>
)}
</Stack>

View file

@ -1,252 +0,0 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Stack,
TextField,
Typography,
} from "@mui/material";
import type {
VendorFacets,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import type { VendorFilters } from "./vendor-filters";
type DraftSetter = Dispatch<SetStateAction<VendorFilters>>;
function FilterSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
{children}
</AccordionDetails>
</Accordion>
);
}
function CompanyFilter({
options,
draft,
setDraft,
}: {
options: VendorFacetCompany[];
draft: VendorFilters;
setDraft: DraftSetter;
}) {
const allSelected = draft.companies.length === options.length && options.length > 0;
return (
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Select vendor companies
</Typography>
<Button
size="small"
onClick={() =>
setDraft((current) => ({
...current,
companies: allSelected ? [] : options.map((company) => company.name),
}))
}
>
{allSelected ? "Clear all" : "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={options}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={options.filter((option) => draft.companies.includes(option.name))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
typeof entry === "string" ? entry : entry.name,
);
setDraft((current) => ({ ...current, companies: next }));
}}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<li key={key} {...optionProps}>
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
{option.name}
</li>
);
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.name;
const { key, ...chipProps } = getItemProps({ index });
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
);
}
function TradeFilter({ options, draft, setDraft }: FilterProps<string>) {
return (
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={options}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const { key, ...chipProps } = getItemProps({ index });
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField {...params} placeholder={draft.trades.length === 0 ? "All trades" : ""} />
)}
/>
</FilterSection>
);
}
interface FilterProps<T> {
options: T[];
draft: VendorFilters;
setDraft: DraftSetter;
}
function LocationFilter({ options, draft, setDraft }: FilterProps<VendorFacetLocation>) {
return (
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={options}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={options.filter((option) => draft.locations.includes(option.label))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
typeof entry === "string" ? entry : entry.label,
);
setDraft((current) => ({ ...current, locations: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const { key, ...chipProps } = getItemProps({ index });
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
);
}
function JobCountFilter({ options, draft, setDraft }: FilterProps<VendorFacetJobBucket>) {
return (
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={options}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={options.filter((option) => draft.jobBuckets.includes(option.id))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
typeof entry === "string" ? entry : entry.id,
);
setDraft((current) => ({ ...current, jobBuckets: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const { key, ...chipProps } = getItemProps({ index });
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
);
}
export function VendorFilterOptions({
facets,
draft,
setDraft,
}: {
facets: VendorFacets | undefined;
draft: VendorFilters;
setDraft: DraftSetter;
}) {
return (
<>
<CompanyFilter options={facets?.companies ?? []} draft={draft} setDraft={setDraft} />
<TradeFilter options={facets?.trades ?? []} draft={draft} setDraft={setDraft} />
<LocationFilter options={facets?.locations ?? []} draft={draft} setDraft={setDraft} />
<JobCountFilter options={facets?.jobBuckets ?? []} draft={draft} setDraft={setDraft} />
</>
);
}

View file

@ -1,9 +1,32 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import BusinessOutlinedIcon from "@mui/icons-material/BusinessOutlined";
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
import CloseIcon from "@mui/icons-material/Close";
import { Box, Button, Chip, Drawer, IconButton, Stack, Typography } from "@mui/material";
import { VendorFilterOptions } from "./vendor-filter-options";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import TagOutlinedIcon from "@mui/icons-material/TagOutlined";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Drawer,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import type {
VendorFacets,
VendorFacetCompany,
VendorFacetJobBucket,
VendorFacetLocation,
} from "@/domain/vendors/types/vendor";
import { emptyVendorFilters, type VendorFilters } from "./vendor-filters";
import type { VendorFacets } from "@/domain/vendors/types/vendor";
interface VendorFiltersDrawerProps {
open: boolean;
@ -15,6 +38,32 @@ interface VendorFiltersDrawerProps {
const DRAWER_WIDTH = 360;
function FilterSection({
title,
icon,
children,
}: {
title: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<Accordion disableGutters elevation={0} square sx={{ borderBottom: 1, borderColor: "divider" }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls={`${title}-filters-content`}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
<Typography variant="subtitle1" className="font-semibold">
{title}
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails id={`${title}-filters-content`} sx={{ pt: 0, pb: 2.5 }}>
{children}
</AccordionDetails>
</Accordion>
);
}
export function VendorFiltersDrawer({
open,
onClose,
@ -25,12 +74,23 @@ export function VendorFiltersDrawer({
const [draft, setDraft] = useState<VendorFilters>(appliedFilters);
useEffect(() => {
if (open) setDraft(appliedFilters);
if (open) {
setDraft(appliedFilters);
}
}, [open, appliedFilters]);
const companyOptions = useMemo(() => facets?.companies ?? [], [facets]);
const tradeOptions = useMemo(() => facets?.trades ?? [], [facets]);
const locationOptions = useMemo(() => facets?.locations ?? [], [facets]);
const jobBucketOptions = useMemo(() => facets?.jobBuckets ?? [], [facets]);
const activeCount =
draft.companies.length + draft.trades.length + draft.locations.length + draft.jobBuckets.length;
const handleClear = () => {
setDraft(emptyVendorFilters);
};
const handleApply = () => {
onApply(draft);
onClose();
@ -73,7 +133,165 @@ export function VendorFiltersDrawer({
</Stack>
<Box className="flex-1 overflow-y-auto" sx={{ px: 2.5 }}>
<VendorFilterOptions facets={facets} draft={draft} setDraft={setDraft} />
<FilterSection title="Company" icon={<BusinessOutlinedIcon />}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Select vendor companies
</Typography>
<Button
size="small"
onClick={() =>
setDraft((current) => ({
...current,
companies:
current.companies.length === companyOptions.length
? []
: companyOptions.map((company) => company.name),
}))
}
>
{draft.companies.length === companyOptions.length && companyOptions.length > 0
? "Clear all"
: "Select all"}
</Button>
</Stack>
<Autocomplete
multiple
size="small"
options={companyOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.name)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.name === value.name
}
value={companyOptions.filter((option) => draft.companies.includes(option.name))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetCompany)[]).map((entry) =>
typeof entry === "string" ? entry : entry.name,
);
setDraft((current) => ({ ...current, companies: next }));
}}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<li key={key} {...optionProps}>
<Checkbox checked={selected} size="small" sx={{ mr: 1 }} />
{option.name}
</li>
);
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.name;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.companies.length === 0 ? "All companies" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Trade" icon={<BuildOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={tradeOptions}
value={draft.trades}
onChange={(_event, value) =>
setDraft((current) => ({ ...current, trades: value as string[] }))
}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={option} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.trades.length === 0 ? "All trades" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Location" icon={<LocationOnOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={locationOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.label === value.label
}
value={locationOptions.filter((option) => draft.locations.includes(option.label))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetLocation)[]).map((entry) =>
typeof entry === "string" ? entry : entry.label,
);
setDraft((current) => ({ ...current, locations: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.locations.length === 0 ? "All locations" : ""}
/>
)}
/>
</FilterSection>
<FilterSection title="Total Jobs" icon={<TagOutlinedIcon />}>
<Autocomplete
multiple
size="small"
options={jobBucketOptions}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label)}
isOptionEqualToValue={(option, value) =>
typeof option === "string" || typeof value === "string"
? option === value
: option.id === value.id
}
value={jobBucketOptions.filter((option) => draft.jobBuckets.includes(option.id))}
onChange={(_event, value) => {
const next = (value as (string | VendorFacetJobBucket)[]).map((entry) =>
typeof entry === "string" ? entry : entry.id,
);
setDraft((current) => ({ ...current, jobBuckets: next }));
}}
renderValue={(value, getItemProps) =>
value.map((option, index) => {
const label = typeof option === "string" ? option : option.label;
const tagProps = getItemProps({ index });
const { key, ...chipProps } = tagProps;
return <Chip key={key} size="small" label={label} {...chipProps} />;
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={draft.jobBuckets.length === 0 ? "All job counts" : ""}
/>
)}
/>
</FilterSection>
</Box>
<Stack
@ -85,16 +303,11 @@ export function VendorFiltersDrawer({
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Button
variant="outlined"
onClick={() => setDraft(emptyVendorFilters)}
disabled={activeCount === 0}
fullWidth
>
<Button variant="outlined" onClick={handleClear} disabled={activeCount === 0} fullWidth>
Clear all
</Button>
<Button variant="contained" onClick={handleApply} fullWidth>
Apply filters
Apply
</Button>
</Stack>
</Stack>

View file

@ -28,7 +28,6 @@ interface VendorRosterFormFieldsProps {
selectedCompanyId?: string | number | null;
onSelectCompany?: (company: VendorFacetCompany | null) => Promise<void>;
onClearSelectedCompany?: (nextName?: string) => void;
showTechnicianStatus?: boolean;
}
function CompanyNameField({
@ -55,7 +54,6 @@ function CompanyNameField({
<TextField
{...field}
label="Company"
placeholder="Start typing a company…"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
@ -100,9 +98,8 @@ function CompanyNameField({
{...params}
label="Company"
required
placeholder="Start typing a company…"
error={Boolean(errors.name)}
helperText={errors.name?.message}
helperText={errors.name?.message ?? "Choose an existing company or enter a new one"}
inputRef={field.ref}
/>
)}
@ -122,7 +119,10 @@ function CompanyFields({
}: VendorRosterFormFieldsProps) {
return (
<Box>
<Stack spacing={2}>
<Typography variant="subtitle2" className="font-semibold uppercase">
Company
</Typography>
<Stack spacing={2} className="mt-2">
<CompanyNameField
control={control}
errors={errors}
@ -138,13 +138,13 @@ function CompanyFields({
render={({ field }) => (
<TextField
{...field}
label="Company Phone (optional)"
placeholder="(214) 555-0100"
label="Company Phone"
placeholder="(XXX) XXX-XXXX"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
error={Boolean(errors.companyPhone)}
helperText={errors.companyPhone?.message}
helperText={errors.companyPhone?.message ?? "Phone or email required"}
fullWidth
/>
)}
@ -155,8 +155,7 @@ function CompanyFields({
render={({ field }) => (
<TextField
{...field}
label="Email (optional)"
placeholder="dispatch@vendor.com"
label="Email"
type="email"
error={Boolean(errors.email)}
helperText={errors.email?.message}
@ -168,16 +167,9 @@ function CompanyFields({
<Controller
control={control}
name="address"
render={({ field }) => (
<TextField
{...field}
label="Address (optional)"
placeholder="123 Industrial Pkwy, Dallas, TX 75201"
fullWidth
/>
)}
render={({ field }) => <TextField {...field} label="Address" fullWidth />}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ display: "none" }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Controller
control={control}
name="city"
@ -202,10 +194,10 @@ function CompanyFields({
render={({ field }) => (
<TextField
{...field}
label="Google Maps URL (optional)"
placeholder="https://maps.google.com/…"
label="Google Maps URL"
placeholder="https://maps.google.com/..."
error={Boolean(errors.googleMapsUrl)}
helperText={errors.googleMapsUrl?.message}
helperText={errors.googleMapsUrl?.message ?? "Must be an HTTPS link"}
fullWidth
/>
)}
@ -214,14 +206,7 @@ function CompanyFields({
control={control}
name="notes"
render={({ field }) => (
<TextField
{...field}
label="Notes (optional)"
placeholder="Internal notes, scheduling preferences, certifications…"
multiline
minRows={2}
fullWidth
/>
<TextField {...field} label="Notes" multiline minRows={2} fullWidth />
)}
/>
</Stack>
@ -236,7 +221,6 @@ interface TechnicianRowProps {
onRemove: () => void;
canRemove: boolean;
tradeOptions: string[];
showStatus: boolean;
}
function TechnicianRow({
@ -246,13 +230,12 @@ function TechnicianRow({
onRemove,
canRemove,
tradeOptions,
showStatus,
}: TechnicianRowProps) {
return (
<Stack spacing={2} className="rounded-md border border-solid border-gray-200 p-3">
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="subtitle2" className="font-semibold">
{index + 1} - TECHNICIAN
Technician {index + 1}
</Typography>
<Tooltip title="Remove technician">
<span>
@ -273,8 +256,7 @@ function TechnicianRow({
render={({ field }) => (
<TextField
{...field}
label="Technician name (optional)"
placeholder="Technician name"
label="Technician Name"
error={Boolean(error?.contactName)}
helperText={error?.contactName?.message}
fullWidth
@ -288,8 +270,8 @@ function TechnicianRow({
render={({ field }) => (
<TextField
{...field}
label="Technician phone (optional)"
placeholder="(000) 000-0000"
label="Technician Phone"
placeholder="(XXX) XXX-XXXX"
inputMode="tel"
value={field.value ?? ""}
onChange={(event) => field.onChange(formatNorthAmericanPhone(event.target.value))}
@ -310,29 +292,26 @@ function TechnicianRow({
error={Boolean(error?.email)}
helperText={error?.email?.message}
fullWidth
sx={{ display: "none" }}
/>
)}
/>
</Stack>
<VendorTradeSpecialtiesField control={control} index={index} tradeOptions={tradeOptions} />
{showStatus && (
<Controller
control={control}
name={`technicians.${index}.isActive`}
render={({ field }) => (
<FormControlLabel
control={
<Switch
checked={field.value}
onChange={(_event, checked) => field.onChange(checked)}
/>
}
label={field.value ? "Active" : "Inactive"}
/>
)}
/>
)}
<Controller
control={control}
name={`technicians.${index}.isActive`}
render={({ field }) => (
<FormControlLabel
control={
<Switch
checked={field.value}
onChange={(_event, checked) => field.onChange(checked)}
/>
}
label={field.value ? "Active" : "Inactive"}
/>
)}
/>
</Stack>
);
}
@ -341,12 +320,10 @@ function TechniciansFieldArray({
control,
errors,
tradeOptions,
showStatus,
}: {
control: Control<VendorCompanyRosterFormValues>;
errors: FieldErrors<VendorCompanyRosterFormValues>;
tradeOptions: string[];
showStatus: boolean;
}) {
const { fields, append, remove } = useFieldArray({ control, name: "technicians" });
@ -389,7 +366,6 @@ function TechniciansFieldArray({
onRemove={() => remove(index)}
canRemove
tradeOptions={tradeOptions}
showStatus={showStatus}
/>
))
)}
@ -399,17 +375,12 @@ function TechniciansFieldArray({
}
export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) {
const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props;
const { control, errors, tradeOptions = [] } = props;
return (
<Stack spacing={3}>
<CompanyFields {...props} />
<Divider />
<TechniciansFieldArray
control={control}
errors={errors}
tradeOptions={tradeOptions}
showStatus={showTechnicianStatus}
/>
<TechniciansFieldArray control={control} errors={errors} tradeOptions={tradeOptions} />
</Stack>
);
}

View file

@ -1,9 +1,13 @@
import { Button, Chip, CircularProgress, Stack, TablePagination } from "@mui/material";
import { Plus, SlidersHorizontal } from "lucide-react";
import { PageHeader } from "@/components/ui/page-header";
import { SearchInput } from "@/components/ui/search-input";
import { Text } from "@/components/ui/text";
import { ToolbarButton } from "@/components/ui/toolbar-button";
import FilterListIcon from "@mui/icons-material/FilterList";
import {
Button,
Chip,
CircularProgress,
Stack,
TablePagination,
TextField,
Typography,
} from "@mui/material";
interface VendorsListToolbarProps {
globalCount: number;
@ -29,44 +33,52 @@ export function VendorsListToolbar({
onAddVendor,
}: VendorsListToolbarProps) {
return (
<PageHeader
surface
className="-mx-4 -mt-4"
title="Vendors / Technicians"
subtitle={`${globalCount} technicians · ${activeCount} active`}
actions={
<>
<SearchInput
value={search}
onChange={onSearchChange}
placeholder="Search name, company, trade…"
aria-label="Search vendors"
inputClassName="h-8 py-0 text-[13px]"
/>
<ToolbarButton
variant="outline"
icon={<SlidersHorizontal size={16} />}
onClick={onOpenFilters}
aria-label="Open vendor filters"
badge={appliedFilterCount}
className="w-[88.609375px] text-[13px]"
>
Filters
</ToolbarButton>
<ToolbarButton
variant="primary"
icon={<Plus size={16} />}
onClick={onAddVendor}
className="w-[115.546875px] border-0 px-3 text-[13px]"
>
Add Vendor
</ToolbarButton>
{isFetching && !isLoading && (
<CircularProgress size={14} aria-label="Refreshing vendors" role="status" />
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Vendors / Technicians
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{globalCount} vendor entries · {activeCount} active
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{ alignItems: { xs: "stretch", sm: "center" } }}
>
<TextField
size="small"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search name, company, trade..."
className="min-w-[260px]"
/>
<Button
variant="outlined"
onClick={onOpenFilters}
startIcon={<FilterListIcon />}
aria-label="Open vendor filters"
>
Filters
{appliedFilterCount > 0 && (
<Chip size="small" color="primary" label={appliedFilterCount} sx={{ ml: 1 }} />
)}
</>
}
/>
</Button>
<Button variant="contained" onClick={onAddVendor}>
+ Add Vendor
</Button>
</Stack>
</Stack>
);
}
@ -78,21 +90,16 @@ interface AppliedChipEntry {
interface VendorAppliedFiltersBarProps {
chips: AppliedChipEntry[];
resultCount: number;
onClearAll: () => void;
}
export function VendorAppliedFiltersBar({
chips,
resultCount,
onClearAll,
}: VendorAppliedFiltersBarProps) {
export function VendorAppliedFiltersBar({ chips, onClearAll }: VendorAppliedFiltersBarProps) {
if (chips.length === 0) return null;
return (
<Stack direction="row" spacing={1} className="flex flex-wrap items-center">
<Text variant="caption" tone="muted">
{resultCount} {resultCount === 1 ? "technician" : "technicians"} · filtered by
</Text>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Applied:
</Typography>
{chips.map((chip) => (
<Chip
key={chip.key}
@ -139,31 +146,23 @@ export function VendorsListFooter({
sx={{
alignItems: { xs: "stretch", sm: "center" },
justifyContent: "space-between",
minHeight: 33.5,
mx: -2,
px: 2,
backgroundColor: "var(--color-bg-surface)",
}}
>
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} {isActive ? "active" : "inactive"}{" "}
{totalCount === 1 ? "technician" : "technicians"}
{totalCount === 1 ? "vendor entry" : "vendor entries"}
{isFiltered && " · filtered"}
</Text>
<Text variant="caption" as="span" sx={{ color: "#6b7a96", fontSize: 11, lineHeight: 1.5 }}>
{activeCount} active · {inactiveCount} inactive
</Text>
{totalCount > pageSize && (
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
rowsPerPageOptions={[24, 48, 96]}
/>
)}
{` · ${activeCount} active · ${inactiveCount} inactive`}
</Typography>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
rowsPerPageOptions={[12, 24, 48]}
/>
</Stack>
);
}

View file

@ -1,10 +1,11 @@
import { type KeyboardEvent, type MouseEvent } from "react";
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
import { useNavigate } from "react-router";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import {
Avatar,
Box,
Chip,
CircularProgress,
IconButton,
Link,
@ -17,8 +18,8 @@ import {
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import { Text } from "@/components/ui/text";
import type { VendorListItem } from "@/domain/vendors/types/vendor";
const TABLE_COLUMNS = [
@ -29,6 +30,9 @@ const TABLE_COLUMNS = [
"Phone",
"Email",
"Total Jobs",
"Status",
"View",
"Edit",
] as const;
function getInitials(name: string): string {
@ -50,34 +54,19 @@ function stopPropagation(event: MouseEvent): void {
event.stopPropagation();
}
function VendorStatus({ isActive }: { isActive: boolean }) {
return (
<Stack direction="row" spacing={1} sx={{ alignItems: "center", pl: 1 }}>
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: "9999px",
backgroundColor: isActive ? "success.main" : "text.secondary",
}}
/>
<Text variant="description">{isActive ? "Active" : "Inactive"}</Text>
</Stack>
);
}
interface VendorTableRowProps {
row: VendorListItem;
onOpenDetail: (row: VendorListItem) => void;
onOpenEdit: (row: VendorListItem) => void;
onOpenDeactivation: (row: VendorListItem) => void;
}
function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) {
function VendorTableRow({ row, onOpenDetail, onOpenDeactivation }: VendorTableRowProps) {
const navigate = useNavigate();
const trades = splitTrades(row.tradeSpecialties);
const primaryTrade = trades[0] ?? "—";
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
const hasVendorId = row.id != null;
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
const handleKeyDown = (event: KeyboardEvent) => {
@ -99,62 +88,37 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
onKeyDown={handleKeyDown}
>
<TableCell>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.companyName || "—"}
</Text>
<Typography className="font-semibold">{row.companyName || "—"}</Typography>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Avatar
sx={{
width: 28,
height: 28,
backgroundColor: "var(--color-bg-muted)",
color: "var(--color-primary)",
fontSize: 10.5,
fontWeight: 600,
fontSize: 12,
backgroundColor: "var(--accent, #1976d2)",
}}
>
{getInitials(row.contactName)}
</Avatar>
<Text variant="description" sx={{ color: "var(--color-foreground)" }}>
{row.contactName || "—"}
</Text>
<Typography variant="body2">{row.contactName || "—"}</Typography>
</Stack>
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>{locationText}</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
<TableCell>{locationText}</TableCell>
<TableCell>
{primaryTrade === "—" ? (
"—"
) : (
<Stack direction="row" spacing={0.5} sx={{ alignItems: "center" }}>
<span>{primaryTrade}</span>
{extraTrades > 0 && (
<Box
component="span"
sx={{
borderRadius: "9999px",
backgroundColor: "var(--color-bg-muted)",
px: 0.75,
py: 0.25,
fontSize: 10.5,
lineHeight: 1.2,
}}
>
+{extraTrades}
</Box>
)}
{extraTrades > 0 && <Chip size="small" label={`+${extraTrades}`} />}
</Stack>
)}
</TableCell>
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
<TableCell>
{row.phone ? (
<Link
href={`tel:${row.phone}`}
underline="hover"
color="inherit"
onClick={stopPropagation}
>
<Link href={`tel:${row.phone}`} underline="hover" onClick={stopPropagation}>
{row.phone}
</Link>
) : (
@ -163,53 +127,71 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
</TableCell>
<TableCell>
{row.email ? (
<Link
href={`mailto:${row.email}`}
underline="hover"
sx={{ color: "#6b7a96" }}
onClick={stopPropagation}
>
<Link href={`mailto:${row.email}`} underline="hover" onClick={stopPropagation}>
{row.email}
</Link>
) : (
"—"
)}
</TableCell>
<TableCell align="center">{row.totalJobs ?? 0}</TableCell>
<TableCell>{row.totalJobs ?? 0}</TableCell>
<TableCell>
<VendorStatus isActive={row.isActive} />
<Chip
size="small"
label={row.isActive ? "Active" : "Inactive"}
color={row.isActive ? "success" : "default"}
/>
</TableCell>
<TableCell align="right">
<TableCell align="center">
<Tooltip title="View details">
<IconButton
size="small"
aria-label={`View vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
onOpenDetail(row);
}}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
<TableCell align="center">
<Stack
direction="row"
spacing={0.5}
className="vendor-row-actions"
sx={{ alignItems: "center", justifyContent: "flex-end" }}
sx={{ alignItems: "center", justifyContent: "center" }}
>
<Tooltip title="View details">
<IconButton
size="small"
aria-label={`View vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
onOpenDetail(row);
}}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
size="small"
aria-label={`Edit vendor ${row.companyName}`}
onClick={(event) => {
event.stopPropagation();
onOpenEdit(row);
navigate(
row.id != null
? `/vendors/edit/${row.id}`
: `/vendors/company/${row.companyId ?? ""}`,
);
}}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
{hasVendorId && row.isActive && (
<Tooltip title="Deactivate">
<IconButton
size="small"
aria-label="Deactivate vendor"
onClick={(event) => {
event.stopPropagation();
onOpenDeactivation(row);
}}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
</TableCell>
</TableRow>
@ -218,118 +200,50 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
interface VendorsTableProps {
rows: VendorListItem[];
isActive: boolean;
isFiltered: boolean;
isLoading: boolean;
showError: boolean;
onOpenDetail: (row: VendorListItem) => void;
onOpenEdit: (row: VendorListItem) => void;
onOpenDeactivation: (row: VendorListItem) => void;
}
export function VendorsTable({
rows,
isActive,
isFiltered,
isLoading,
showError,
onOpenDetail,
onOpenEdit,
onOpenDeactivation,
}: VendorsTableProps) {
const showEmptyRow = !isLoading && !showError && rows.length === 0;
const columnCount = TABLE_COLUMNS.length + 2;
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{
mt: 2,
mb: 2,
flex: "1 1 auto",
overflow: "auto",
borderRadius: 1.5,
fontSize: 13,
}}
>
<Table
size="small"
stickyHeader
sx={{
minWidth: 1550,
tableLayout: "fixed",
"& .MuiTableCell-head": {
backgroundColor: "var(--color-bg-muted)",
color: "var(--color-foreground)",
fontSize: 10.5,
fontWeight: 700,
letterSpacing: "0.06em",
lineHeight: "24px",
px: 1.75,
py: 1.375,
textTransform: "uppercase",
whiteSpace: "nowrap",
},
"& .MuiTableCell-body": {
fontSize: 13,
px: 1.5,
py: 1,
height: 52,
lineHeight: 1.43,
whiteSpace: "nowrap",
},
"& .vendor-row-actions": {
opacity: { xs: 1, md: 0 },
transition: "opacity 120ms ease",
},
"& .MuiTableRow-root:hover .vendor-row-actions, & .MuiTableRow-root:focus-within .vendor-row-actions":
{
opacity: 1,
},
}}
>
<colgroup>
<col style={{ width: 196.34375 }} />
<col style={{ width: 207.25 }} />
<col style={{ width: 152.703125 }} />
<col style={{ width: 218.15625 }} />
<col style={{ width: 152.703125 }} />
<col style={{ width: 251.90625 }} />
<col style={{ width: 109.078125 }} />
<col style={{ width: 119.984375 }} />
<col style={{ width: 141.875 }} />
</colgroup>
<TableContainer component={Paper} variant="outlined" sx={{ overflowX: "auto" }}>
<Table size="small" sx={{ minWidth: 960 }}>
<TableHead>
<TableRow>
{TABLE_COLUMNS.map((column) => (
<TableCell key={column} align={column === "Total Jobs" ? "center" : "left"}>
<TableCell
key={column}
align={column === "View" || column === "Edit" ? "center" : "left"}
className="font-semibold"
>
{column}
</TableCell>
))}
<TableCell>Status</TableCell>
<TableCell align="right" aria-label="Vendor actions" />
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columnCount} align="center" className="py-10">
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : showEmptyRow ? (
<TableRow>
<TableCell colSpan={columnCount} align="center" className="py-10">
<Stack spacing={1} sx={{ alignItems: "center" }}>
<SearchOffOutlinedIcon sx={{ color: "text.secondary", fontSize: 32 }} />
<Text variant="description" className="font-semibold">
{`No ${isActive ? "active" : "inactive"} technicians found`}
</Text>
<Text variant="caption" tone="muted">
{isFiltered
? "Try a different search, or add a new vendor."
: "Add a vendor to get started."}
</Text>
</Stack>
<TableCell colSpan={TABLE_COLUMNS.length} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>
No vendors match the current filters.
</Typography>
</TableCell>
</TableRow>
) : (
@ -338,7 +252,7 @@ export function VendorsTable({
key={String(row.id ?? `company-${row.companyId ?? ""}`)}
row={row}
onOpenDetail={onOpenDetail}
onOpenEdit={onOpenEdit}
onOpenDeactivation={onOpenDeactivation}
/>
))
)}

View file

@ -23,91 +23,16 @@ interface AppliedChip {
label: string;
}
function statusTabLabel(label: string, count: number) {
return (
<Box className="flex items-center gap-2">
<Box component="span">{label}</Box>
<Box
component="span"
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
>
{count}
</Box>
</Box>
);
}
function VendorStatusTabs({
isActive,
activeCount,
inactiveCount,
onChange,
}: {
isActive: boolean;
activeCount: number;
inactiveCount: number;
onChange: (isActive: boolean) => void;
}) {
return (
<Tabs
value={isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") => onChange(value === "active")}
aria-label="Vendor status"
sx={{
minHeight: 36,
flexShrink: 0,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
backgroundColor: "var(--color-bg-surface)",
mx: -2,
px: 1.5,
"& .MuiTab-root": {
minHeight: 36,
px: 2,
py: 0,
fontSize: 13,
fontWeight: 500,
textTransform: "none",
},
"& .MuiTab-root:first-of-type": { width: 102.84375 },
"& .Mui-selected": { fontWeight: 600 },
"& .MuiTabs-indicator": { height: 2 },
}}
>
<Tab
value="active"
label={statusTabLabel("Active", activeCount)}
aria-label={`Active (${activeCount})`}
/>
<Tab
value="inactive"
label={statusTabLabel("Inactive", inactiveCount)}
aria-label={`Inactive (${inactiveCount})`}
/>
</Tabs>
);
}
function describeChips(
filters: VendorFilters,
jobBucketLabels: Map<string, string>,
): AppliedChip[] {
const chips: AppliedChip[] = [];
filters.companies.forEach((value) =>
chips.push({ category: "companies", value, label: `Company: ${value}` }),
);
filters.trades.forEach((value) =>
chips.push({ category: "trades", value, label: `Trade: ${value}` }),
);
filters.locations.forEach((value) =>
chips.push({ category: "locations", value, label: `Location: ${value}` }),
);
filters.companies.forEach((value) => chips.push({ category: "companies", value, label: value }));
filters.trades.forEach((value) => chips.push({ category: "trades", value, label: value }));
filters.locations.forEach((value) => chips.push({ category: "locations", value, label: value }));
filters.jobBuckets.forEach((value) =>
chips.push({
category: "jobBuckets",
value,
label: `Total Jobs: ${jobBucketLabels.get(value) ?? value}`,
}),
chips.push({ category: "jobBuckets", value, label: jobBucketLabels.get(value) ?? value }),
);
return chips;
}
@ -117,12 +42,7 @@ export default function VendorsListPage() {
const [filtersOpen, setFiltersOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [detailTarget, setDetailTarget] = useState<VendorListItem | null>(null);
const [detailStartsInEdit, setDetailStartsInEdit] = useState(false);
const closeDetail = () => {
setDetailTarget(null);
setDetailStartsInEdit(false);
};
const deactivation = useVendorDeactivation(closeDetail);
const deactivation = useVendorDeactivation();
const { data: facets } = useVendorFacets(filters.isActive);
const { data, isLoading, isFetching, error } = useVendorsList(filters.listParams);
@ -141,7 +61,7 @@ export default function VendorsListPage() {
const appliedChips = describeChips(filters.appliedFilters, jobBucketLabels);
return (
<Box className="flex h-full flex-col overflow-hidden px-4 pt-4 pb-0">
<Box className="flex flex-col gap-4 p-4">
<VendorsListToolbar
globalCount={activeCount + inactiveCount}
activeCount={activeCount}
@ -154,15 +74,18 @@ export default function VendorsListPage() {
onAddVendor={() => setCreateOpen(true)}
/>
<VendorStatusTabs
isActive={filters.isActive}
activeCount={activeCount}
inactiveCount={inactiveCount}
onChange={filters.setIsActiveWithReset}
/>
<Tabs
value={filters.isActive ? "active" : "inactive"}
onChange={(_event, value: "active" | "inactive") =>
filters.setIsActiveWithReset(value === "active")
}
aria-label="Vendor status"
>
<Tab value="active" label={`Active (${activeCount})`} />
<Tab value="inactive" label={`Inactive (${inactiveCount})`} />
</Tabs>
<VendorAppliedFiltersBar
resultCount={totalCount}
chips={appliedChips.map((chip) => ({
key: `${chip.category}-${chip.value}`,
label: chip.label,
@ -179,18 +102,10 @@ export default function VendorsListPage() {
<VendorsTable
rows={rows}
isActive={filters.isActive}
isFiltered={appliedChips.length > 0 || Boolean(filters.debouncedSearch)}
isLoading={isLoading}
showError={Boolean(error)}
onOpenDetail={(row) => {
setDetailStartsInEdit(false);
setDetailTarget(row);
}}
onOpenEdit={(row) => {
setDetailStartsInEdit(true);
setDetailTarget(row);
}}
onOpenDetail={setDetailTarget}
onOpenDeactivation={deactivation.open}
/>
<VendorsListFooter
@ -218,9 +133,7 @@ export default function VendorsListPage() {
<VendorDetailDrawer
open={Boolean(detailTarget)}
vendor={detailTarget}
startInEdit={detailStartsInEdit}
onRequestDeactivation={deactivation.open}
onClose={closeDetail}
onClose={() => setDetailTarget(null)}
/>
<VendorDeactivationDialog

View file

@ -0,0 +1,19 @@
import type { ElementType } from "react";
type CompDocCoLineProps = {
icon: ElementType;
text: string;
bold?: boolean;
};
export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
return (
<div
className="flex items-center justify-end gap-1.5 break-words"
style={{ fontSize: 11, color: "#161E38", overflowWrap: "break-word" }}
>
<span style={{ fontWeight: bold ? 600 : 400 }}>{text}</span>
<Icon size={12} className="shrink-0" style={{ color: "#1C75BC" }} />
</div>
);
}

View file

@ -0,0 +1,39 @@
import type { ElementType } from "react";
type CompDocDetailRowProps = {
icon: ElementType;
label: string;
value: string;
last?: boolean;
};
export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
return (
<div
className="flex items-start gap-2.5"
style={{
padding: "10px 0",
borderBottom: last ? "none" : "1px solid var(--color-border)",
}}
>
<Icon
size={15}
className="shrink-0"
style={{ color: "var(--muted-foreground)", marginTop: 2 }}
/>
<div className="min-w-0">
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}>{label}</div>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--color-text-primary)",
marginTop: 1,
}}
>
{value}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,70 @@
import type { RefObject } from "react";
import { Ban, Save, Sparkles, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
type CompDocDialogFooterDraftActionsProps = {
rowVersion?: string | number | null;
isUploading: boolean;
pdfInputRef: RefObject<HTMLInputElement | null>;
onMarkNotRequired: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
};
export function CompDocDialogFooterDraftActions({
rowVersion,
isUploading,
pdfInputRef,
onMarkNotRequired,
onSaveDraft,
onGenerate,
}: CompDocDialogFooterDraftActionsProps) {
return (
<>
<Button
variant="ghost"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ color: "var(--muted-foreground)" }}
onClick={onMarkNotRequired}
>
<Ban size={14} /> Mark as not required
</Button>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onSaveDraft}
>
<Save size={14} /> Save draft
</Button>
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
disabled={!rowVersion}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!isUploading ? <Upload size={14} /> : undefined}
>
Upload PDF
</Button>
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onGenerate}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,62 @@
import type { RefObject } from "react";
import { CheckCircle2, Download, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
type CompDocDialogFooterGeneratedActionsProps = {
generatedAt: string;
signOffAttachment?: string | null;
isDocumentsReadOnly: boolean;
rowVersion?: string | number | null;
isUploading: boolean;
pdfInputRef: RefObject<HTMLInputElement | null>;
onDownloadPdf: () => void;
};
export function CompDocDialogFooterGeneratedActions({
generatedAt,
signOffAttachment,
isDocumentsReadOnly,
rowVersion,
isUploading,
pdfInputRef,
onDownloadPdf,
}: CompDocDialogFooterGeneratedActionsProps) {
return (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--color-success)" }}
>
<CheckCircle2 size={14} />{" "}
{signOffAttachment ? "Document on file" : `Generated ${generatedAt}`}
</span>
<div className="flex shrink-0 items-center gap-2">
{!isDocumentsReadOnly && (
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
disabled={!rowVersion}
loading={isUploading}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!isUploading ? <Upload size={14} /> : undefined}
>
Replace PDF
</Button>
)}
<Button
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{ background: "var(--color-primary)", color: "#fff" }}
onClick={onDownloadPdf}
>
<Download size={14} /> Download PDF
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,53 @@
import { Ban, Sparkles, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
type CompDocDialogFooterNotRequiredActionsProps = {
isDocumentsReadOnly: boolean;
onUndo: () => void;
};
export function CompDocDialogFooterNotRequiredActions({
isDocumentsReadOnly,
onUndo,
}: CompDocDialogFooterNotRequiredActionsProps) {
return (
<>
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
<b style={{ fontWeight: 600 }}>not required</b> for this work order
</span>
<div className="flex shrink-0 items-center gap-2">
{!isDocumentsReadOnly && (
<Button
variant="outline"
size="default"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
onClick={onUndo}
>
<Undo2 size={14} /> Undo
</Button>
)}
<Button
size="default"
disabled
title="Marked not required — undo to generate"
className="gap-1.5 whitespace-nowrap text-[12px]"
sx={COMP_DOC_FOOTER_BTN_SX}
style={{
background: "var(--color-primary)",
color: "#fff",
opacity: 0.5,
pointerEvents: "none",
}}
>
<Sparkles size={14} /> Generate
</Button>
</div>
</>
);
}

View file

@ -0,0 +1,94 @@
import type { RefObject } from "react";
import type { DocState } from "@/domain/work-orders/types/completion-doc";
import { CompDocDialogFooterDraftActions } from "./comp-doc-dialog-footer-draft-actions";
import { CompDocDialogFooterGeneratedActions } from "./comp-doc-dialog-footer-generated-actions";
import { CompDocDialogFooterNotRequiredActions } from "./comp-doc-dialog-footer-not-required-actions";
type CompDocDialogFooterProps = {
docState: DocState;
isDocumentsReadOnly: boolean;
rowVersion?: string | number | null;
isUploading: boolean;
signOffAttachment?: string | null;
generatedAt: string;
pdfInputRef: RefObject<HTMLInputElement | null>;
onMarkNotRequired: () => void;
onSaveDraft: () => void;
onGenerate: () => void;
onUndo: () => void;
onDownloadPdf: () => void;
onUploadPdf: (files: FileList | null) => void;
};
export function CompDocDialogFooter({
docState,
isDocumentsReadOnly,
rowVersion,
isUploading,
signOffAttachment,
generatedAt,
pdfInputRef,
onMarkNotRequired,
onSaveDraft,
onGenerate,
onUndo,
onDownloadPdf,
onUploadPdf,
}: CompDocDialogFooterProps) {
// Exclusive footer branches (prototype: one docState at a time). A signed PDF
// on file implies Generated even if local docState has not caught up yet — never
// render Draft actions alongside Generated.
const showNotRequired = docState === "not-required";
const showGenerated =
!showNotRequired && (docState === "generated" || Boolean(signOffAttachment));
const showDraft = !showNotRequired && !showGenerated && docState === "draft";
return (
<div
className="flex shrink-0 items-center justify-between gap-3"
style={{
padding: "14px 20px",
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(event) => {
onUploadPdf(event.target.files);
event.target.value = "";
}}
/>
{Boolean(showDraft && !isDocumentsReadOnly) && (
<CompDocDialogFooterDraftActions
rowVersion={rowVersion}
isUploading={isUploading}
pdfInputRef={pdfInputRef}
onMarkNotRequired={onMarkNotRequired}
onSaveDraft={onSaveDraft}
onGenerate={onGenerate}
/>
)}
{showGenerated && (
<CompDocDialogFooterGeneratedActions
generatedAt={generatedAt}
signOffAttachment={signOffAttachment}
isDocumentsReadOnly={isDocumentsReadOnly}
rowVersion={rowVersion}
isUploading={isUploading}
pdfInputRef={pdfInputRef}
onDownloadPdf={onDownloadPdf}
/>
)}
{showNotRequired && (
<CompDocDialogFooterNotRequiredActions
isDocumentsReadOnly={isDocumentsReadOnly}
onUndo={onUndo}
/>
)}
</div>
);
}

View file

@ -0,0 +1,96 @@
import { FileText, X } from "lucide-react";
import type { CompletionDocStateConfig } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocDialogHeaderProps = {
info: WorkOrderTableRow;
cfg: CompletionDocStateConfig;
isUploading: boolean;
onClose: () => void;
};
export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
return (
<div
className="flex shrink-0 items-center justify-between"
style={{
height: 60,
padding: "0 20px",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
<div className="min-w-0">
<div
style={{
fontFamily: "var(--font-display)",
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
lineHeight: 1.1,
}}
>
Completion Document
</div>
<div
className="truncate"
style={{
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
WO{" "}
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
#{info.woNumber}
</span>
{" · "}
{serviceTitle(info)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className="inline-flex items-center gap-1.5"
style={{
height: 26,
padding: "0 10px",
borderRadius: 9999,
background: cfg.pillBg,
color: cfg.pillText,
fontSize: 12,
fontWeight: 600,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 9999,
background: cfg.pillText,
}}
/>
{cfg.pillLabel}
</span>
<button
type="button"
onClick={onClose}
disabled={isUploading}
aria-label="Close"
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
style={{
width: 32,
height: 32,
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={16} />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
import { Download, FileText } from "lucide-react";
type CompDocDialogResourceLinksProps = {
templateUrl?: string | null;
signOffAttachment?: string | null;
};
export function CompDocDialogResourceLinks({
templateUrl,
signOffAttachment,
}: CompDocDialogResourceLinksProps) {
if (!templateUrl && !signOffAttachment) return null;
return (
<div
className="flex shrink-0 flex-wrap items-center gap-3 px-5 py-2"
style={{
borderBottom: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
{Boolean(templateUrl) && (
<a
href={templateUrl ?? undefined}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<Download size={13} />
Download blank template
</a>
)}
{Boolean(signOffAttachment) && (
<a
href={signOffAttachment ?? undefined}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<FileText size={13} />
Open uploaded PDF
</a>
)}
</div>
);
}

View file

@ -0,0 +1,132 @@
import type { MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
import { CompDocPreview } from "./comp-doc-preview";
import { CompDocSidebar } from "./comp-doc-sidebar";
import { useCompDocDialog } from "./use-comp-doc-dialog";
type CompDocDialogProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
};
export function CompDocDialog({
row,
open,
onOpenChange,
data,
onSave,
onDateCompletedChange,
}: CompDocDialogProps) {
const dialog = useCompDocDialog({
row,
open,
data,
onSave,
onDateCompletedChange,
});
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex h-[85vh] max-h-[85vh] flex-col overflow-hidden border border-border p-0",
style: {
borderRadius: 12,
width: "min(1024px, calc(100vw - 32px))",
maxWidth: "none",
},
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent
dividers={false}
className="flex min-h-0 flex-1 flex-col gap-0 overflow-hidden p-0"
sx={{
p: 0,
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<DialogTitle className="sr-only">
Completion Document — WO #{dialog.info.woNumber}
</DialogTitle>
<Text className="sr-only" variant="description">
Generate, save or mark not-required the completion document for this work order.
</Text>
<div className="flex min-h-0 flex-1 flex-col">
<CompDocDialogHeader
info={dialog.info}
cfg={dialog.cfg}
isUploading={dialog.isUploading}
onClose={() => handleClose?.()}
/>
<CompDocDialogResourceLinks
templateUrl={dialog.templateUrl}
signOffAttachment={dialog.signOffAttachment}
/>
<div className="flex min-h-0 flex-1 flex-row">
<CompDocPreview
row={dialog.info}
site={dialog.site}
notes={dialog.notes}
generatedAt={dialog.generatedAt}
isGenerated={dialog.isGenerated}
isNotRequired={dialog.isNotRequired}
/>
<CompDocSidebar
row={dialog.info}
site={dialog.site}
docState={dialog.docState}
generatedAt={dialog.generatedAt}
notes={dialog.notes}
editDateCompleted={dialog.editDateCompleted}
amazonRep={dialog.amazonRep}
seaRep={dialog.seaRep}
isGenerated={dialog.isGenerated}
onNotesChange={dialog.setNotes}
onDateCompletedChange={dialog.setEditDateCompleted}
/>
</div>
<CompDocDialogFooter
docState={dialog.docState}
isDocumentsReadOnly={dialog.isDocumentsReadOnly}
rowVersion={dialog.rowVersion}
isUploading={dialog.isUploading}
signOffAttachment={dialog.signOffAttachment}
generatedAt={dialog.generatedAt}
pdfInputRef={dialog.pdfInputRef}
onMarkNotRequired={dialog.handleMarkNotRequired}
onSaveDraft={dialog.handleSaveDraft}
onGenerate={dialog.handleGenerate}
onUndo={dialog.handleUndo}
onDownloadPdf={dialog.handleDownloadPdf}
onUploadPdf={dialog.handleUploadPdf}
/>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,51 @@
import type { CSSProperties } from "react";
const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
const COMP_DOC_INPUT_STYLE: CSSProperties = {
fontSize: 13,
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
};
type CompDocEditRowProps = {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
readOnly?: boolean;
};
export function CompDocEditRow({
label,
value,
onChange,
type = "text",
readOnly = false,
}: CompDocEditRowProps) {
return (
<div style={{ padding: "8px 0", borderBottom: "1px solid var(--color-border)" }}>
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)", marginBottom: 4 }}>
{label}
</div>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
readOnly={readOnly}
className={COMP_DOC_INPUT_CLS}
style={{
...COMP_DOC_INPUT_STYLE,
...(readOnly
? {
opacity: 0.6,
cursor: "default",
pointerEvents: "none" as const,
}
: {}),
}}
/>
</div>
);
}

View file

@ -0,0 +1,8 @@
export const COMP_DOC_FOOTER_BTN_SX = {
minHeight: 28,
height: 28,
fontSize: 12,
px: 1.25,
py: 0.5,
gap: 0.75,
} as const;

View file

@ -0,0 +1,36 @@
import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
import { CompDocCoLine } from "./comp-doc-co-line";
export function CompDocPreviewCompanyHeader() {
return (
<>
<div
className="flex flex-wrap items-start justify-between gap-y-3"
style={{ padding: "20px 0 12px" }}
>
<div className="flex items-center">
<img
src="/seahaven-logo.svg"
alt="Seahaven Industries"
style={{ height: 34, width: "auto", display: "block" }}
/>
</div>
<div className="space-y-1">
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
</div>
</div>
<div
style={{
height: 3,
borderRadius: 2,
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
}}
/>
</>
);
}

View file

@ -0,0 +1,58 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocPreviewCompanyHeader } from "./comp-doc-preview-company-header";
import { CompDocPreviewProcedureNotes } from "./comp-doc-preview-procedure-notes";
import { CompDocPreviewServiceInfo } from "./comp-doc-preview-service-info";
import { CompDocPreviewVendorSafety } from "./comp-doc-preview-vendor-safety";
type CompDocPreviewDocumentProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
notes: string;
generatedAt: string;
isGenerated: boolean;
isNotRequired: boolean;
};
export function CompDocPreviewDocument({
row,
site,
notes,
generatedAt,
isGenerated,
isNotRequired,
}: CompDocPreviewDocumentProps) {
return (
<div
style={{
opacity: isNotRequired ? 0.5 : 1,
filter: isNotRequired ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>
<CompDocPreviewCompanyHeader />
<CompDocPreviewServiceInfo
row={row}
site={site}
isGenerated={isGenerated}
generatedAt={generatedAt}
/>
<CompDocPreviewVendorSafety row={row} />
<CompDocPreviewProcedureNotes pm={row.pm} notes={notes} />
</div>
<div
style={{
height: 24,
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { Ban } from "lucide-react";
export function CompDocPreviewNotRequiredStamp() {
return (
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
<div
className="inline-flex items-center gap-2"
style={{
marginTop: "30%",
transform: "rotate(-15deg)",
border: "3px solid var(--warning)",
color: "var(--warning)",
borderRadius: 8,
padding: "8px 18px",
fontWeight: 800,
fontSize: 22,
letterSpacing: "0.06em",
background: "rgba(255,255,255,0.65)",
}}
>
<Ban size={20} /> NOT REQUIRED
</div>
</div>
);
}

View file

@ -0,0 +1,68 @@
import { Text } from "@/components/ui/text";
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
type CompDocPreviewProcedureNotesProps = {
pm: string;
notes: string;
};
export function CompDocPreviewProcedureNotes({ pm, notes }: CompDocPreviewProcedureNotesProps) {
const proc = procedureFor(pm);
return (
<>
<Text
as="h3"
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<Text
as="p"
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#58595B",
margin: "0 0 8px",
}}
>
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{Boolean(notes) && (
<>
<Text
as="h3"
style={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<Text
as="p"
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
</>
);
}

View file

@ -0,0 +1,89 @@
import { CheckCircle2 } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
type CompDocPreviewServiceInfoProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
isGenerated: boolean;
generatedAt: string;
};
export function CompDocPreviewServiceInfo({
row,
site,
isGenerated,
generatedAt,
}: CompDocPreviewServiceInfoProps) {
return (
<>
{isGenerated && (
<div
className="inline-flex items-center gap-1.5"
style={{
marginTop: 16,
padding: "4px 10px",
borderRadius: 9999,
background: "var(--doc-yes-bg)",
color: "var(--color-success)",
fontSize: 11.5,
fontWeight: 600,
}}
>
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
</div>
)}
<Text
as="h2"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 700,
letterSpacing: "0.02em",
color: "#161E38",
margin: "16px 0 4px",
}}
>
{serviceTitle(row).toUpperCase()}
</Text>
<Text
as="p"
className="font-mono"
style={{
fontSize: 12.5,
fontWeight: 600,
color: "#1C75BC",
margin: "2px 0",
}}
>
WO# {row.woNumber}
</Text>
<div style={{ marginTop: 14 }}>
<Text
as="p"
style={{
fontSize: 13,
fontWeight: 600,
color: "#161E38",
margin: "2px 0",
}}
>
Site ID — {row.site}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
Amazon.com Services LLC {row.site}
</Text>
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.street},
</Text>
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
{site.label}
</Text>
</div>
</>
);
}

View file

@ -0,0 +1,32 @@
import { CheckCircle2, FileText } from "lucide-react";
type CompDocPreviewStatusBadgeProps = {
isGenerated: boolean;
};
export function CompDocPreviewStatusBadge({ isGenerated }: CompDocPreviewStatusBadgeProps) {
return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
className="inline-flex items-center gap-1.5"
style={{
height: 28,
padding: "0 12px",
borderRadius: 9999,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
fontSize: 12,
fontWeight: 500,
color: "var(--muted-foreground)",
}}
>
{isGenerated ? (
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
) : (
<FileText size={13} />
)}
{isGenerated ? "Generated document" : "Preview — not yet generated"}
</span>
</div>
);
}

View file

@ -0,0 +1,35 @@
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
type CompDocPreviewVendorSafetyProps = {
row: WorkOrderTableRow;
};
export function CompDocPreviewVendorSafety({ row }: CompDocPreviewVendorSafetyProps) {
return (
<>
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
<span style={{ fontWeight: 700 }}>Vendor / Technician:&nbsp;</span>
{row.tech || "—"}
{row.company ? ` · ${row.company}` : ""}
</Text>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{SAFETY_BULLETS.map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
}}
>
{b}
</li>
))}
</ul>
</>
);
}

View file

@ -0,0 +1,65 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { CompDocPreviewDocument } from "./comp-doc-preview-document";
import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp";
import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge";
type CompDocPreviewProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
notes: string;
generatedAt: string;
isGenerated: boolean;
isNotRequired: boolean;
};
export function CompDocPreview({
row,
site,
notes,
generatedAt,
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
return (
<div
className="flex min-h-0 flex-col"
style={{
width: "58%",
borderRight: "1px solid var(--color-border)",
background: "var(--color-bg-muted)",
}}
>
<CompDocPreviewStatusBadge isGenerated={isGenerated} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px" }}
>
<div
className="relative break-words"
style={{
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
>
<CompDocPreviewDocument
row={row}
site={site}
notes={notes}
generatedAt={generatedAt}
isGenerated={isGenerated}
isNotRequired={isNotRequired}
/>
{isNotRequired && <CompDocPreviewNotRequiredStamp />}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
type CompDocProcedureListProps = {
proc: Procedure;
};
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
return (
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
{proc.steps.map((st, i) => (
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
{st.text}
{st.sub != null && (
<ol
style={{
listStyle: "lower-alpha",
paddingLeft: 22,
margin: "4px 0 0",
}}
>
{st.sub.map((x, j) => (
<li key={j} style={{ margin: "2px 0" }}>
{x}
</li>
))}
</ol>
)}
</li>
))}
</ol>
);
}

View file

@ -0,0 +1,174 @@
import { Building2, MapPin, Users, Wrench } from "lucide-react";
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
DOC_STATE_CFG,
docStatusCardDescription,
} from "@/domain/work-orders/utils/completion-doc-state";
import { CompDocDetailRow } from "./comp-doc-detail-row";
import { CompDocEditRow } from "./comp-doc-edit-row";
type CompDocSidebarProps = {
row: WorkOrderTableRow;
site: CompDocSiteInfo;
docState: DocState;
generatedAt: string;
notes: string;
editDateCompleted: string;
amazonRep: string;
seaRep: string;
isGenerated: boolean;
onNotesChange: (value: string) => void;
onDateCompletedChange: (value: string) => void;
};
export function CompDocSidebar({
row,
site,
docState,
generatedAt,
notes,
editDateCompleted,
amazonRep,
seaRep,
isGenerated,
onNotesChange,
onDateCompletedChange,
}: CompDocSidebarProps) {
const displayState: DocState =
isGenerated && docState !== "not-required" ? "generated" : docState;
const cfg = DOC_STATE_CFG[displayState];
const StateIcon = cfg.icon;
return (
<div
className="flex min-h-0 flex-col"
style={{ width: "42%", background: "var(--color-bg-surface)" }}
>
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
<div
className="flex items-start gap-3"
style={{
padding: 14,
borderRadius: 10,
background: cfg.cardBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<div
className="flex shrink-0 items-center justify-center"
style={{
width: 34,
height: 34,
borderRadius: 8,
background: cfg.cardIconBg,
border: `1px solid ${cfg.cardBorder}`,
}}
>
<StateIcon size={17} style={{ color: cfg.iconColor }} />
</div>
<div className="min-w-0">
<div
style={{
fontSize: 13.5,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{cfg.cardTitle}
</div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.45,
color: "var(--muted-foreground)",
marginTop: 3,
}}
>
{docStatusCardDescription(displayState, generatedAt)}
</div>
</div>
</div>
<div style={{ marginTop: 20 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
}}
>
Work Order Details
</div>
<div style={{ marginTop: 6 }}>
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
<CompDocDetailRow
icon={MapPin}
label="Site Code"
value={`${row.site} · Amazon.com Services LLC`}
/>
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
<CompDocEditRow
label="Date Work Completed"
value={editDateCompleted}
onChange={onDateCompletedChange}
type="date"
readOnly={isGenerated}
/>
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
<CompDocDetailRow
icon={Wrench}
label="Seahaven Representative"
value={seaRep || "—"}
last
/>
</div>
</div>
<div style={{ marginTop: 18 }}>
<label
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
PM Comments{" "}
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
— optional, included in the document
</span>
</label>
<textarea
value={notes}
onChange={(e) => onNotesChange(e.target.value)}
readOnly={isGenerated}
rows={3}
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
className="mt-2 w-full resize-none"
style={{
fontSize: 13,
fontFamily: "var(--font-sans)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: "10px 12px",
lineHeight: 1.5,
outline: "none",
...(isGenerated
? {
opacity: 0.6,
cursor: "default",
pointerEvents: "none" as const,
}
: {}),
}}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,59 @@
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import type { CompDocData, DocState } from "@/domain/work-orders/types/completion-doc";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import { docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
type UseCompDocDialogSeedArgs = {
open: boolean;
infoId: string | number;
infoDocStatus: CompletionDocStatus;
infoCompletedDate?: string | null;
completionDocStatus?: CompletionDocStatus | null;
data?: CompDocData;
setNotes: Dispatch<SetStateAction<string>>;
setDocState: Dispatch<SetStateAction<DocState>>;
setGeneratedAt: Dispatch<SetStateAction<string>>;
setEditDateCompleted: Dispatch<SetStateAction<string>>;
};
/** Prototype seeds once per open/WO session; detail refetches must not wipe edits. */
export function useCompDocDialogSeed({
open,
infoId,
infoDocStatus,
infoCompletedDate,
completionDocStatus,
data,
setNotes,
setDocState,
setGeneratedAt,
setEditDateCompleted,
}: UseCompDocDialogSeedArgs) {
const seedKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!open) {
seedKeyRef.current = null;
return;
}
const key = String(infoId);
if (seedKeyRef.current === key) return;
seedKeyRef.current = key;
setNotes(data?.notes ?? "");
setDocState(docStateOf(completionDocStatus || infoDocStatus));
setGeneratedAt(data?.generatedAt ?? "");
setEditDateCompleted(infoCompletedDate || "");
}, [
open,
infoId,
infoDocStatus,
infoCompletedDate,
completionDocStatus,
data?.notes,
data?.generatedAt,
setNotes,
setDocState,
setGeneratedAt,
setEditDateCompleted,
]);
}

View file

@ -0,0 +1,179 @@
import { useMemo, useRef, useState } from "react";
import { toast } from "react-toastify";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import type {
CompDocData,
CompDocSaveStatus,
DocFields,
DocState,
} from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
import { DOC_STATE_CFG } from "@/domain/work-orders/utils/completion-doc-state";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { useCompDocDialogSeed } from "./use-comp-doc-dialog-seed";
import { useCompDocNotRequiredActions } from "./use-comp-doc-not-required-actions";
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
type UseCompDocDialogArgs = {
row: WorkOrderTableRow;
open: boolean;
data?: CompDocData;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
};
function notifyDraftSaved() {
toast(
<div
className="flex items-center gap-2"
style={{
background: "var(--foreground)",
color: "#fff",
padding: "10px 18px",
borderRadius: 9999,
fontSize: 13,
fontWeight: 600,
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
}}
>
Draft saved
</div>,
{
position: "bottom-center",
autoClose: 3000,
hideProgressBar: true,
closeButton: false,
icon: false,
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
},
);
}
export function useCompDocDialog({
row,
open,
data,
onSave,
onDateCompletedChange,
}: UseCompDocDialogArgs) {
const locId = String(row.locationId || "");
const { data: locationDetail } = useLocationDetail(locId || undefined);
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
const pdfInputRef = useRef<HTMLInputElement>(null);
const [isFinalizing, setIsFinalizing] = useState(false);
const info = boardDetail?.info ?? row;
const completion = boardDetail?.completion;
const templateUrl = completion?.template?.templateUrl;
const signOffAttachment = completion?.signOffAttachment;
const rowVersion = info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
const [notes, setNotes] = useState("");
const [docState, setDocState] = useState<DocState>("draft");
const [generatedAt, setGeneratedAt] = useState("");
const [editDateCompleted, setEditDateCompleted] = useState("");
useCompDocDialogSeed({
open,
infoId: info.id,
infoDocStatus: info.docStatus,
infoCompletedDate: info.completedDate,
completionDocStatus: completion?.docStatus,
data,
setNotes,
setDocState,
setGeneratedAt,
setEditDateCompleted,
});
const amazonRep = info.pocName || "";
const seaRep = info.tech || "";
const cfg = DOC_STATE_CFG[docState];
const isNotRequired = docState === "not-required";
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
const docFields: DocFields = {
street: site.street,
city: site.city,
state: site.state,
zip: site.zip,
dateCompleted: editDateCompleted,
amazonRep,
seaRep,
};
const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
useCompDocPersistFlow({
workOrderId: row.id,
notes,
generatedAt,
editDateCompleted,
currentCompletedDate: info.completedDate || "",
onSave,
onDateCompletedChange,
onGenerated: (ts) => {
setGeneratedAt(ts);
setDocState("generated");
},
onDraftSaved: notifyDraftSaved,
});
const { handleMarkNotRequired, handleUndo } = useCompDocNotRequiredActions({
docState,
setDocState,
persist,
refreshAuthoritativeState,
setIsFinalizing,
});
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
info,
signOffAttachment,
rowVersion,
notes,
generatedAt,
docFields,
uploadCompletionMutation,
persist,
syncDateIfChanged,
setGeneratedAt,
markGenerated: () => setDocState("generated"),
onPersistFailure: refreshAuthoritativeState,
onFinalizingChange: setIsFinalizing,
});
return {
info,
templateUrl,
signOffAttachment,
rowVersion,
isDocumentsReadOnly,
site,
notes,
setNotes,
docState,
generatedAt,
editDateCompleted,
setEditDateCompleted,
amazonRep,
seaRep,
cfg,
isNotRequired,
isGenerated,
isUploading: uploadCompletionMutation.isPending || isFinalizing,
pdfInputRef,
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
handleUploadPdf,
};
}

View file

@ -0,0 +1,58 @@
import { toast } from "react-toastify";
import type {
CompDocData,
CompDocSaveStatus,
DocState,
} from "@/domain/work-orders/types/completion-doc";
type PersistFn = (data: Partial<CompDocData>, status: CompDocSaveStatus) => void | Promise<void>;
type UseCompDocNotRequiredActionsArgs = {
docState: DocState;
setDocState: (state: DocState) => void;
persist: PersistFn;
refreshAuthoritativeState: () => void;
setIsFinalizing: (value: boolean) => void;
};
export function useCompDocNotRequiredActions({
docState,
setDocState,
persist,
refreshAuthoritativeState,
setIsFinalizing,
}: UseCompDocNotRequiredActionsArgs) {
async function handleMarkNotRequired() {
const previous = docState;
setDocState("not-required");
setIsFinalizing(true);
try {
await persist({}, "NN");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(
error instanceof Error ? error.message : "Failed to mark document as not required",
);
} finally {
setIsFinalizing(false);
}
}
async function handleUndo() {
const previous = docState;
setDocState("draft");
setIsFinalizing(true);
try {
await persist({}, "No");
} catch (error) {
setDocState(previous);
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
} finally {
setIsFinalizing(false);
}
}
return { handleMarkNotRequired, handleUndo };
}

View file

@ -0,0 +1,98 @@
import { toast } from "react-toastify";
import type {
CompDocData,
CompDocSaveStatus,
DocFields,
} from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
import {
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
type UseCompDocPdfActionsArgs = {
info: WorkOrderTableRow;
signOffAttachment?: string | null;
rowVersion?: string;
notes: string;
generatedAt: string;
docFields: DocFields;
uploadCompletionMutation: ReturnType<typeof useUploadCompletionDoc>;
persist: (extra: Partial<CompDocData>, status: CompDocSaveStatus) => Promise<void>;
syncDateIfChanged: () => Promise<void>;
setGeneratedAt: (value: string) => void;
markGenerated: () => void;
onPersistFailure?: () => void;
onFinalizingChange?: (finalizing: boolean) => void;
};
export function useCompDocPdfActions({
info,
signOffAttachment,
rowVersion,
notes,
generatedAt,
docFields,
uploadCompletionMutation,
persist,
syncDateIfChanged,
setGeneratedAt,
markGenerated,
onPersistFailure,
onFinalizingChange,
}: UseCompDocPdfActionsArgs) {
function handleDownloadPdf() {
if (signOffAttachment) {
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
return;
}
printDoc(buildDocHtml(info, { notes, generatedAt }, docFields));
}
function handleUploadPdf(files: FileList | null) {
const file = files?.[0];
if (!file) return;
if (isWorkOrderFileTooLarge(file)) {
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
return;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
toast.error("Please upload a PDF file.");
return;
}
if (!rowVersion) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate(
{ file, workOrderVersion: rowVersion },
{
onSuccess: () => {
// Keep close disabled across the gap between upload settle and docStatus persist.
onFinalizingChange?.(true);
void (async () => {
const ts = fmtDocNow();
try {
await syncDateIfChanged();
await persist({ generatedAt: ts }, "Yes");
setGeneratedAt(ts);
markGenerated();
} catch (error) {
onPersistFailure?.();
toast.error(
error instanceof Error ? error.message : "Failed to finalize completion document",
);
} finally {
onFinalizingChange?.(false);
}
})();
},
},
);
}
return { handleDownloadPdf, handleUploadPdf };
}

View file

@ -0,0 +1,84 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
import { queryKeys } from "@/infra/query-key/query-key";
type UseCompDocPersistFlowArgs = {
workOrderId: string | number;
notes: string;
generatedAt: string;
editDateCompleted: string;
currentCompletedDate: string;
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
onDateCompletedChange?: (date: string) => void | Promise<void>;
onGenerated: (generatedAt: string) => void;
onDraftSaved: () => void;
};
export function useCompDocPersistFlow({
workOrderId,
notes,
generatedAt,
editDateCompleted,
currentCompletedDate,
onSave,
onDateCompletedChange,
onGenerated,
onDraftSaved,
}: UseCompDocPersistFlowArgs) {
const queryClient = useQueryClient();
const refreshAuthoritativeState = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
});
void queryClient.invalidateQueries({
queryKey: queryKeys.workOrders.detail(workOrderId),
});
};
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
await onSave({ notes, generatedAt, ...extra }, status);
};
const syncDateIfChanged = async () => {
if (editDateCompleted !== currentCompletedDate) {
await onDateCompletedChange?.(editDateCompleted);
}
};
async function handleSaveDraft() {
try {
// Prerequisites first; docStatus last.
await syncDateIfChanged();
await persist({}, "No");
onDraftSaved();
} catch (error) {
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to save draft");
}
}
async function handleGenerate() {
const ts = fmtDocNow();
try {
// Prerequisites first; only mark Generated after completedDate persists.
await syncDateIfChanged();
await persist({ generatedAt: ts }, "Yes");
onGenerated(ts);
} catch (error) {
refreshAuthoritativeState();
toast.error(error instanceof Error ? error.message : "Failed to generate document");
}
}
return {
persist,
syncDateIfChanged,
refreshAuthoritativeState,
handleSaveDraft,
handleGenerate,
};
}

View file

@ -0,0 +1,67 @@
import { useMemo } from "react";
import { Box, Typography } from "@mui/material";
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
import { formatDateTime } from "@/lib/time-utils";
type AuditTimelineProps = {
entries: WorkOrderAuditEntry[];
emptyMessage?: string;
maxHeightClass?: string;
};
export function AuditTimeline({
entries,
emptyMessage = "No changes recorded.",
maxHeightClass = "max-h-72",
}: AuditTimelineProps) {
const sorted = useMemo(
() =>
[...entries].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
),
[entries],
);
if (!sorted.length) {
return <Typography color="text.secondary">{emptyMessage}</Typography>;
}
return (
<Box className={`${maxHeightClass} space-y-3 overflow-y-auto`}>
{sorted.map((entry) => (
<Box key={String(entry.id)} className="rounded border border-border p-3">
<Box className="mb-1 flex justify-between gap-2">
<Typography variant="caption" className="font-semibold">
{entry.userName || "System"}
</Typography>
<Typography variant="caption" color="text.secondary">
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
</Typography>
</Box>
<Typography variant="body2">
{entry.action ? (
entry.action
) : (
<>
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
</>
)}
</Typography>
{Boolean(entry.action && entry.fieldName) && (
<Typography
variant="caption"
color="text.secondary"
className="mt-1"
sx={{ display: "block" }}
>
{entry.fieldName}
{entry.oldValue || entry.newValue
? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}`
: ""}
</Typography>
)}
</Box>
))}
</Box>
);
}

View file

@ -0,0 +1,164 @@
import { Settings } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import {
getAvatarColorFromName,
getDispatcherAvatarColor,
getInitialsFromName,
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
if (entry.type === "system") return true;
if (entry.type === "manual") return false;
const name = entry.userName?.trim();
return !name || name.toLowerCase() === "system";
}
function formatClock(date: Date): string {
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
});
}
/**
* Manual (image): `· 9:48 AM`
* System (image): `05/13 · 8:00 AM`
* Prefer API display strings that already include · / AM|PM.
*/
function formatAuditTime(value: string, isSystem: boolean): string {
if (!value) return "";
if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const timePart = formatClock(date);
if (isSystem) {
const datePart = date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
});
return `${datePart} · ${timePart}`;
}
return `· ${timePart}`;
}
type SlideOverAuditListProps = {
entries: WorkOrderAuditEntry[];
users?: WorkOrderUser[];
};
export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListProps) {
return (
<div>
<Text
as="p"
className="mb-3 text-[10px] font-medium uppercase"
style={{ letterSpacing: "0.06em", color: "var(--color-text-muted)" }}
>
Audit Log
</Text>
<div className="space-y-2.5">
{entries.length === 0 ? (
<Text
as="p"
className="py-4 text-center text-[12px]"
style={{ color: "var(--muted-foreground)" }}
>
No changes recorded
</Text>
) : (
entries.map((entry, index) => {
const isSys = isSystemEntry(entry);
const dispatcher = users.find(
(user) =>
entry.dispatcherId != null && String(user.id) === String(entry.dispatcherId),
);
const name =
dispatcher?.name ||
entry.userName?.trim() ||
(entry.dispatcherId ? String(entry.dispatcherId) : "Unknown");
const color = dispatcher
? getDispatcherAvatarColor(dispatcher)
: getAvatarColorFromName(name);
const initials = dispatcher
? getInitialsFromName(dispatcher.name)
: getInitialsFromName(name);
const action = formatBoardAuditAction(entry);
return (
<div
key={String(entry.id) || index}
className="flex items-start gap-2.5"
style={
isSys
? {
background: "#F8FAFC",
borderRadius: 6,
padding: "10px",
}
: {
background: "transparent",
padding: "10px 0",
}
}
>
{isSys ? (
<div
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded"
style={{ background: "#E2E8F0" }}
>
<Settings size={10} style={{ color: "#64748B" }} />
</div>
) : (
<div
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[8px] font-bold"
style={
dispatcher || name !== "Unknown"
? { background: `${color}25`, color }
: {
background: "var(--color-bg-muted)",
color: "var(--color-text-muted)",
}
}
>
{initials}
</div>
)}
<div className="min-w-0 flex-1">
{isSys ? (
<Text as="p" className="text-[12px] leading-snug" style={{ color: "#374151" }}>
<span style={{ fontStyle: "italic", color: "#6B7280" }}>System</span>
<span style={{ color: "#6B7280" }}> — </span>
{action}
</Text>
) : (
<Text as="p" className="text-[12px] leading-snug">
<span className="font-semibold" style={{ color: "#1E293B" }}>
{name}
</span>
<span style={{ color: "#D1D5DB" }}> · </span>
<span style={{ color: "#374151" }}>{action}</span>
</Text>
)}
<Text
as="p"
className="mt-0.5 text-[10px]"
style={{
fontFamily: "var(--font-mono)",
color: "#9CA3AF",
}}
>
{formatAuditTime(entry.createdAt, isSys)}
</Text>
</div>
</div>
);
})
)}
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
import { AlertTriangle, Ban, Check, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
type SlideOverFooterProps = {
isCancelled: boolean;
editing: boolean;
isDirty: boolean;
showUnsaved: boolean;
saving?: boolean;
onCancelWO: () => void;
onCancelEdit: () => void;
onClose: () => void;
onEdit: () => void;
onSave: () => void;
onDiscard: () => void;
onKeepEditing: () => void;
};
export function SlideOverFooter({
isCancelled,
editing,
isDirty,
showUnsaved,
saving,
onCancelWO,
onCancelEdit,
onClose,
onEdit,
onSave,
onDiscard,
onKeepEditing,
}: SlideOverFooterProps) {
if (showUnsaved) {
return (
<div
className="flex shrink-0 items-center justify-between gap-2 px-5 py-3"
style={{
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<span
className="flex items-center gap-1.5 text-[12px]"
style={{ color: "var(--color-warning)" }}
>
<AlertTriangle size={13} />
You have unsaved changes.
</span>
<div className="flex gap-2">
<Button variant="ghost" className="text-[12px]" onClick={onDiscard}>
Discard
</Button>
<Button
variant="default"
className="gap-1.5 text-[12px]"
onClick={onSave}
loading={saving}
>
<Check size={13} />
Save
</Button>
<Button variant="ghost" className="text-[12px]" onClick={onKeepEditing}>
Keep editing
</Button>
</div>
</div>
);
}
if (isCancelled) {
return (
<div
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
style={{
background: "var(--color-bg-muted)",
borderTop: "1px solid var(--color-border)",
}}
>
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
This work order has been cancelled and is read-only.
</Text>
</div>
);
}
return (
<div
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
style={{
borderTop: "1px solid var(--color-border)",
background: "var(--color-bg-surface)",
}}
>
<button
type="button"
onClick={onCancelWO}
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
style={{ color: "var(--destructive)" }}
>
<AlertTriangle size={13} />
Cancel WO
</button>
<div className="flex items-center gap-3">
<button
type="button"
onClick={editing ? onCancelEdit : onClose}
disabled={saving}
className="text-[12px] transition-colors"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--color-text-primary)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = "var(--color-text-muted)";
}}
>
Cancel
</button>
{editing ? (
<Button
variant="default"
className="gap-1.5 text-[12px]"
onClick={onSave}
disabled={!isDirty}
loading={saving}
>
<Check size={13} />
Save
</Button>
) : (
<Button variant="default" className="gap-1.5 text-[12px]" onClick={onEdit}>
<Pencil size={13} />
Edit WO
</Button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,106 @@
import { CircularProgress } from "@mui/material";
import { X } from "lucide-react";
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
import { Text } from "@/components/ui/text";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
type SlideOverHeaderProps = {
row: WorkOrderTableRow;
isCancelled: boolean;
isFetching?: boolean;
closeDisabled?: boolean;
onClose: () => void;
};
export function SlideOverHeader({
row,
isCancelled,
isFetching = false,
closeDisabled = false,
onClose,
}: SlideOverHeaderProps) {
return (
<div
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3.5"
style={{ borderBottom: "1px solid var(--color-border)" }}
>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Text
as="h2"
className="text-[15px] font-semibold leading-none"
style={{ color: "var(--color-text-primary)" }}
>
#{row.woNumber || "—"}
</Text>
{isCancelled && (
<span
className="rounded-full px-2 py-0.5 text-[12px] font-medium"
style={{
background: "var(--color-bg-muted)",
color: "var(--muted-foreground)",
border: "1px solid var(--color-border)",
}}
>
Cancelled
</span>
)}
{Boolean(row.site) && (
<span
className="rounded px-1.5 py-0.5 font-mono text-[11px]"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)",
}}
>
{row.site}
</span>
)}
{row.isPastDue ? (
<span
className="inline-flex items-center gap-1.5 text-[11px] font-medium leading-none"
style={{
background: "var(--status-pastdue-bg)",
color: "var(--status-pastdue-text)",
borderRadius: 4,
padding: "3px 8px",
}}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#BE123C]" />
Past Due
</span>
) : row.status ? (
<WoTableStatusLabel status={row.status} />
) : null}
{Boolean(row.type) && <WoTableTypeLabel type={row.type as WOType} />}
</div>
<div className="flex shrink-0 items-center gap-1">
{isFetching && <CircularProgress size={14} aria-label="Refreshing details" />}
<button
type="button"
aria-label="Close"
onClick={onClose}
disabled={closeDisabled}
className="shrink-0 rounded p-1.5 transition-colors disabled:opacity-40"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
if (!closeDisabled) {
e.currentTarget.style.background = "var(--color-bg-muted)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
<X size={15} />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
import type { ReactNode } from "react";
type SlideOverInfoRowProps = {
label: string;
children: ReactNode;
};
export function SlideOverInfoRow({ label, children }: SlideOverInfoRowProps) {
return (
<div
className="flex items-center gap-2 py-1.5"
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
>
<span className="w-28 shrink-0 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
{label}
</span>
<div className="min-w-0 flex-1 text-[12.5px]" style={{ color: "var(--color-text-primary)" }}>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
type SlideOverAuditTabProps = {
entries: WorkOrderAuditEntry[];
users?: WorkOrderUser[];
};
export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) {
return <SlideOverAuditList entries={entries} users={users} />;
}

View file

@ -0,0 +1,265 @@
import { useState } from "react";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
import {
getAvatarColorFromName,
getInitialsFromName,
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
function formatCommentTime(value: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
const textareaStyle = {
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
} as const;
type SlideOverCommentItemProps = {
comment: WorkOrderComment;
canEdit: boolean;
isEditing: boolean;
draftText: string;
isUpdatePending: boolean;
onStartEdit: () => void;
onDraftChange: (value: string) => void;
onCancelEdit: () => void;
onSaveEdit: () => void;
};
function SlideOverCommentItem({
comment,
canEdit,
isEditing,
draftText,
isUpdatePending,
onStartEdit,
onDraftChange,
onCancelEdit,
onSaveEdit,
}: SlideOverCommentItemProps) {
const author = comment.userName || comment.commenter || "Unknown";
const color = getAvatarColorFromName(author);
const initials = getInitialsFromName(author);
return (
<div className="flex items-start gap-2.5 py-2.5">
<span
className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
style={{ background: `${color}25`, color }}
>
{initials}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<span
className="truncate text-[12px] font-semibold"
style={{ color: "var(--color-text-primary)" }}
>
{author}
</span>
<span className="shrink-0 text-[10px]" style={{ color: "var(--color-text-muted)" }}>
{formatCommentTime(comment.createdDate)}
</span>
</div>
{canEdit && !isEditing && (
<button
type="button"
title="Edit comment"
aria-label="Edit comment"
onClick={onStartEdit}
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70"
style={{ color: "var(--color-text-muted)" }}
>
<Pencil size={12} />
</button>
)}
</div>
{isEditing ? (
<div className="mt-1.5 space-y-2">
<textarea
value={draftText}
onChange={(event) => onDraftChange(event.target.value)}
rows={3}
disabled={isUpdatePending}
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
style={textareaStyle}
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="default"
className="h-8 text-[12px]"
disabled={isUpdatePending}
onClick={onCancelEdit}
style={{ color: "var(--color-text-muted)" }}
>
Cancel
</Button>
<Button
size="default"
className="h-8 text-[12px]"
disabled={!draftText.trim() || isUpdatePending}
loading={isUpdatePending}
onClick={onSaveEdit}
>
Save
</Button>
</div>
</div>
) : (
<Text
as="p"
className="mt-0.5 text-[12px] leading-snug"
style={{ color: "var(--color-text-secondary)" }}
>
{comment.commenttext}
</Text>
)}
</div>
</div>
);
}
type SlideOverCommentsTabProps = {
comments: WorkOrderComment[];
isReadOnly: boolean;
isPending: boolean;
currentUserId?: string | number;
isUpdatePending?: boolean;
onSubmit: (text: string, options?: { onSuccess?: () => void }) => void;
onUpdateComment?: (
commentId: string | number,
text: string,
options?: { onSuccess?: () => void },
) => void;
};
export function SlideOverCommentsTab({
comments,
isReadOnly,
isPending,
currentUserId,
isUpdatePending = false,
onSubmit,
onUpdateComment,
}: SlideOverCommentsTabProps) {
const [commentText, setCommentText] = useState("");
const [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
const [draftText, setDraftText] = useState("");
const handlePost = () => {
const text = commentText.trim();
if (!text || isReadOnly || isPending) return;
onSubmit(text, { onSuccess: () => setCommentText("") });
};
const startEdit = (comment: WorkOrderComment) => {
setEditingCommentId(comment.id);
setDraftText(comment.commenttext);
};
const cancelEdit = () => {
setEditingCommentId(null);
setDraftText("");
};
const saveEdit = () => {
if (editingCommentId == null || !onUpdateComment) return;
const text = draftText.trim();
if (!text || isUpdatePending) return;
onUpdateComment(editingCommentId, text, {
onSuccess: () => {
setEditingCommentId(null);
setDraftText("");
},
});
};
return (
<div className="space-y-3">
{!isReadOnly && (
<>
<textarea
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Add comment…"
rows={3}
disabled={isPending}
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
style={textareaStyle}
/>
<div className="flex justify-end">
<Button
size="default"
className="h-8 text-[12px]"
disabled={!commentText.trim() || isPending}
onClick={handlePost}
loading={isPending}
>
Post
</Button>
</div>
</>
)}
<div
style={{
borderTop: "1px solid var(--color-bg-muted)",
pointerEvents: isReadOnly ? "none" : undefined,
}}
>
{comments.length === 0 ? (
<Text
as="p"
className="py-6 text-center text-[12px]"
style={{ color: "var(--muted-foreground)" }}
>
No comments yet
</Text>
) : (
comments.map((comment) => {
const canEdit =
!isReadOnly &&
Boolean(onUpdateComment) &&
isOwnWorkOrderComment(comment, currentUserId);
const isEditing = editingCommentId === comment.id;
return (
<div
key={String(comment.id)}
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
>
<SlideOverCommentItem
comment={comment}
canEdit={canEdit}
isEditing={isEditing}
draftText={isEditing ? draftText : comment.commenttext}
isUpdatePending={isUpdatePending && isEditing}
onStartEdit={() => startEdit(comment)}
onDraftChange={setDraftText}
onCancelEdit={cancelEdit}
onSaveEdit={saveEdit}
/>
</div>
);
})
)}
</div>
</div>
);
}

View file

@ -0,0 +1,92 @@
import type { RefObject } from "react";
import { Ban, CheckCircle2, FileText, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverCompletionActionsProps = {
docStatus: CompletionDocStatus;
rowVersion?: string | number | null;
completionPending: boolean;
hasUploadHandler: boolean;
pdfInputRef: RefObject<HTMLInputElement | null>;
onPdfSelected: (files: FileList | null) => void;
onOpenCompletionDoc: () => void;
};
function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) {
if (docStatus === "Yes") {
return (
<>
<CheckCircle2 size={13} />
View / Edit Document
</>
);
}
if (docStatus === "NN") {
return (
<>
<Ban size={13} />
Document Not Required — Review
</>
);
}
return (
<>
<FileText size={13} />
Open completion document
</>
);
}
export function SlideOverCompletionActions({
docStatus,
rowVersion,
completionPending,
hasUploadHandler,
pdfInputRef,
onPdfSelected,
onOpenCompletionDoc,
}: SlideOverCompletionActionsProps) {
return (
<div className="space-y-2">
{hasUploadHandler && (
<>
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
disabled={!rowVersion}
loading={completionPending}
loadingText="Uploading…"
onClick={() => pdfInputRef.current?.click()}
startIcon={!completionPending ? <Upload size={13} /> : undefined}
>
Upload signed PDF
</Button>
<input
ref={pdfInputRef}
type="file"
accept="application/pdf,.pdf"
hidden
onChange={(event) => {
onPdfSelected(event.target.files);
event.target.value = "";
}}
/>
{!rowVersion && (
<Text as="p" style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Waiting for work order version before upload is available.
</Text>
)}
</>
)}
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
onClick={onOpenCompletionDoc}
>
<DocStatusButtonContent docStatus={docStatus} />
</Button>
</div>
);
}

View file

@ -0,0 +1,52 @@
import { Image as ImageIcon } from "lucide-react";
import {
MediaUploader,
type MediaUploadRequest,
} from "@/app/(protected)/workorders/_components/media/media-uploader";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
type SlideOverCompletionMediaProps = {
isReadOnly: boolean;
media: MediaFile[];
mediaPending: boolean;
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
onRemoveMedia?: (id: string) => void;
onRetryMedia?: (localId: string) => void;
onCancelMedia?: (localId: string) => void;
};
export function SlideOverCompletionMedia({
isReadOnly,
media,
mediaPending,
onUploadMedia,
onCategorizeMedia,
onRemoveMedia,
onRetryMedia,
onCancelMedia,
}: SlideOverCompletionMediaProps) {
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Photos &amp; Videos
</span>
</div>
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
<MediaUploader
uploads={media}
onUpload={onUploadMedia}
onCategorize={onCategorizeMedia}
onRemove={onRemoveMedia}
onRetry={onRetryMedia}
onCancel={onCancelMedia}
readOnly={isReadOnly}
pending={mediaPending}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import { CheckCircle2, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverCompletionReadonlyStatusProps = {
docStatus: CompletionDocStatus;
onOpenCompletionDoc: () => void;
};
export function SlideOverCompletionReadonlyStatus({
docStatus,
onOpenCompletionDoc,
}: SlideOverCompletionReadonlyStatusProps) {
if (docStatus === "Yes") {
return (
<Button
variant="outline"
className="w-full gap-1.5 text-[12px]"
onClick={onOpenCompletionDoc}
>
<CheckCircle2 size={13} />
View Document
</Button>
);
}
return (
<div
className="flex items-center gap-2"
style={{
padding: "10px 12px",
borderRadius: 8,
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<FileText size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
{docStatus === "NN"
? "No completion document was required for this work order."
: "No completion document was generated."}
</span>
</div>
);
}

View file

@ -0,0 +1,35 @@
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
type SlideOverCompletionSignoffsProps = {
completion?: WorkOrderBoardCompletion;
};
export function SlideOverCompletionSignoffs({ completion }: SlideOverCompletionSignoffsProps) {
if (!completion || completion.dispatchSignoffs.length === 0) return null;
return (
<div className="space-y-1 pt-2">
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--color-text-muted)",
}}
>
Dispatch sign-offs
</span>
{completion.dispatchSignoffs.map((signoff, index) => (
<div
key={`${signoff.dispatchId ?? "d"}-${index}`}
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
>
{signoff.signoffType || "Sign-off"}
{signoff.name ? ` — ${signoff.name}` : ""}
{signoff.signedAt ? ` · ${signoff.signedAt}` : ""}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,143 @@
import { useRef } from "react";
import { Download, FileText } from "lucide-react";
import { toast } from "react-toastify";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import {
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status";
import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
}
type SlideOverCompletionTabProps = {
row: WorkOrderTableRow;
isReadOnly: boolean;
completion?: WorkOrderBoardCompletion;
media: MediaFile[];
mediaPending?: boolean;
completionPending?: boolean;
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
onRemoveMedia?: (id: string) => void;
onRetryMedia?: (localId: string) => void;
onCancelMedia?: (localId: string) => void;
onUploadCompletionPdf?: (file: File) => void;
onOpenCompletionDoc: () => void;
};
export function SlideOverCompletionTab({
row,
isReadOnly,
completion,
media,
mediaPending = false,
completionPending = false,
onUploadMedia,
onCategorizeMedia,
onRemoveMedia,
onRetryMedia,
onCancelMedia,
onUploadCompletionPdf,
onOpenCompletionDoc,
}: SlideOverCompletionTabProps) {
const pdfInputRef = useRef<HTMLInputElement>(null);
const docStatus = completion?.docStatus || row.docStatus;
const templateUrl = completion?.template?.templateUrl;
const signOffAttachment = completion?.signOffAttachment;
if (isMediaWO(row.type)) {
return (
<SlideOverCompletionMedia
isReadOnly={isReadOnly}
media={media}
mediaPending={mediaPending}
onUploadMedia={onUploadMedia}
onCategorizeMedia={onCategorizeMedia}
onRemoveMedia={onRemoveMedia}
onRetryMedia={onRetryMedia}
onCancelMedia={onCancelMedia}
/>
);
}
const handlePdfSelected = (files: FileList | null) => {
const file = files?.[0];
if (!file || !onUploadCompletionPdf) return;
if (isWorkOrderFileTooLarge(file)) {
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
return;
}
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
toast.error("Please upload a PDF file.");
return;
}
onUploadCompletionPdf(file);
};
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<FileText size={14} style={{ color: "var(--foreground)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Completion Document
</span>
</div>
{Boolean(templateUrl) && (
<a
href={templateUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<Download size={13} />
Download blank template
</a>
)}
{Boolean(signOffAttachment) && (
<a
href={signOffAttachment}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<FileText size={13} />
View uploaded document
</a>
)}
{isReadOnly ? (
<SlideOverCompletionReadonlyStatus
docStatus={docStatus}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
) : (
<SlideOverCompletionActions
docStatus={docStatus}
rowVersion={row.rowVersion}
completionPending={completionPending}
hasUploadHandler={Boolean(onUploadCompletionPdf)}
pdfInputRef={pdfInputRef}
onPdfSelected={handlePdfSelected}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
)}
<SlideOverCompletionSignoffs completion={completion} />
</div>
);
}

View file

@ -0,0 +1,44 @@
import { Image as ImageIcon } from "lucide-react";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
}
const DOC_BADGE_STYLE: Record<"Yes" | "No" | "NN", { bg: string; text: string }> = {
Yes: { bg: "#F0FDF4", text: "#166534" },
No: { bg: "#FFF1F2", text: "#BE123C" },
NN: { bg: "#F3F4F6", text: "#6B7280" },
};
export function DocBadge({ row }: { row: WorkOrderTableRow }) {
if (isMediaWO(row.type)) {
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
style={{ color: "var(--color-primary)" }}
>
<ImageIcon size={14} />
{row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"}
</span>
);
}
const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No";
const style = DOC_BADGE_STYLE[key];
const label = key === "NN" ? "NN" : key;
return (
<span
className="inline-flex items-center px-1.5 py-0.5 text-[11px] font-medium leading-none"
style={{
background: style.bg,
color: style.text,
borderRadius: 4,
}}
>
{label}
</span>
);
}

View file

@ -0,0 +1,163 @@
import { useRef } from "react";
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Upload,
X,
} from "lucide-react";
import { toast } from "react-toastify";
import { Text } from "@/components/ui/text";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
};
type SlideOverExtrasTabProps = {
uploads: MediaFile[];
isReadOnly: boolean;
pending?: boolean;
onUpload: (files: File[]) => void;
onRemove: (id: string) => void;
};
export function SlideOverExtrasTab({
uploads,
isReadOnly,
pending = false,
onUpload,
onRemove,
}: SlideOverExtrasTabProps) {
const inputRef = useRef<HTMLInputElement>(null);
const addFiles = (files: FileList | null) => {
if (isReadOnly || pending || !files || files.length === 0) return;
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderMediaTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
}
if (accepted.length > 0) {
onUpload(accepted);
}
};
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
Extra Documents
</span>
</div>
{!isReadOnly && (
<>
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
addFiles(event.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
style={{
minHeight: 100,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
aria-busy={pending || undefined}
>
{pending ? "Uploading…" : "Upload documents, photos or videos"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept={workOrderMediaAcceptAttribute()}
multiple
hidden
disabled={pending}
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
</>
)}
{uploads.length === 0 ? (
<Text as="p" style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
No extra documents yet.
</Text>
) : (
<div className="space-y-1">
{uploads.map((file) => {
const ItemIcon = FILETYPE_ICON[file.fileType];
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
return (
<div key={file.id} className="flex items-center gap-2 py-1">
<ItemIcon
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="min-w-0 flex-1 truncate hover:underline"
style={{ fontSize: 12, color: "var(--foreground)" }}
>
{file.name}
</a>
{showDelete && (
<button
type="button"
title="Remove"
disabled={pending}
onClick={() => onRemove(file.id)}
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
style={{ color: "var(--color-text-muted)" }}
>
<X size={12} />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,28 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
type SlideOverInfoTabAssignedFieldProps = {
dispatcher?: WorkOrderUser;
dispatcherName?: string;
};
export function SlideOverInfoTabAssignedField({
dispatcher,
dispatcherName,
}: SlideOverInfoTabAssignedFieldProps) {
if (!dispatcher && !dispatcherName) {
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}
return (
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
{dispatcher != null && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: getDispatcherAvatarColor(dispatcher) }}
/>
)}
{dispatcher?.name || dispatcherName}
</span>
);
}

View file

@ -0,0 +1,92 @@
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
import type { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
import { DocBadge } from "./slide-over-doc-badge";
import { SlideOverInfoTabAssignedField } from "./slide-over-info-tab-assigned-field";
import { SlideOverInfoTabStatusField } from "./slide-over-info-tab-status-field";
import { SlideOverInfoTabVendorField } from "./slide-over-info-tab-vendor-field";
type SlideOverInfoTabDisplayViewProps = {
source: WorkOrderTableRow;
isCancelled: boolean;
dispatcher?: WorkOrderUser;
vendors: VendorDropdownItem[];
};
export function SlideOverInfoTabDisplayView({
source,
isCancelled,
dispatcher,
vendors,
}: SlideOverInfoTabDisplayViewProps) {
const appt = parseApptTime(source.apptTime);
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
return (
<div style={isCancelled ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
<SlideOverInfoRow label="WO #">
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Site">
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}>
{source.site || "—"}
</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
{source.type ? (
<WoTableTypeLabel type={source.type as WOType} />
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
<SlideOverInfoTabAssignedField
dispatcher={dispatcher}
dispatcherName={source.dispatcherName}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Schedule On">
<span className="text-[12.5px]">
{source.scheduleWeekOnly && source.targetWeek
? `Week of ${source.targetWeek}`
: source.scheduledOn || "—"}
</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<span className="text-[12.5px]">{source.dueDate || "—"}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<span className="text-[12.5px]">{apptDisplay}</span>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
{source.pm ? (
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
<PmTypeIcon pmType={source.pm} size={14} />
{source.pm}
</span>
) : (
<span style={{ color: "var(--color-text-muted)" }}>—</span>
)}
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<SlideOverInfoTabVendorField source={source} vendors={vendors} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">
<SlideOverInfoTabStatusField source={source} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Comp Doc">
<DocBadge row={source} />
</SlideOverInfoRow>
<SlideOverInfoRow label="POC">
<span className="text-[12.5px]" style={{ color: "var(--color-text-secondary)" }}>
{source.pocName || "—"}
{source.pocPhone ? ` · ${source.pocPhone}` : ""}
</span>
</SlideOverInfoRow>
</div>
);
}

View file

@ -0,0 +1,160 @@
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { DocBadge } from "./slide-over-doc-badge";
type SlideOverInfoTabEditViewProps = {
draft: WorkOrderTableRow;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function SlideOverInfoTabEditView({
draft,
users,
sites,
vendors,
pmTypes,
allOrders,
onDraftChange,
onDuplicateFound,
}: SlideOverInfoTabEditViewProps) {
return (
<div>
<SlideOverInfoRow label="WO #">
<EditableWONumberCell
value={draft.woNumber}
rescheduleCount={draft.rescheduleCount}
carriedOver={draft.carriedOver}
currentId={draft.id}
allOrders={allOrders}
onSave={(woNumber) => onDraftChange({ woNumber })}
onDuplicateFound={onDuplicateFound}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Site">
<SiteCell
value={draft.site}
location={draft.location}
locationId={draft.locationId}
pocName={draft.pocName}
pocPhone={draft.pocPhone}
pocNotes={draft.pocNotes}
sites={sites}
onSave={(patch) =>
onDraftChange({
site: patch.site,
location: patch.location,
locationId: patch.locationId,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Type of WO">
<TypeCell value={draft.type} onSave={(type) => onDraftChange({ type })} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Assigned To">
<AssignedToCell
dispatcherId={draft.dispatcherId}
users={users}
onSave={(id, name) => onDraftChange({ dispatcherId: id, dispatcherName: name })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Schedule On">
<DateCell
value={draft.scheduledOn}
allowWeekMode
targetWeek={draft.targetWeek}
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
onSave={(scheduledOn) =>
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
}
onSaveWeek={(weekStart) =>
onDraftChange({
scheduledOn: "",
targetWeek: weekStart,
scheduleWeekOnly: true,
isPastDue: false,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Due Date">
<DateCell value={draft.dueDate} onSave={(dueDate) => onDraftChange({ dueDate })} />
</SlideOverInfoRow>
<SlideOverInfoRow label="Appt Time">
<ApptTimeCell
apptTime={draft.apptTime}
onSave={(apptTime) => onDraftChange({ apptTime })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
<PMTypeCell
value={draft.pm}
pmTypes={pmTypes}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
woNumber={draft.woNumber}
onSave={(pm) => onDraftChange({ pm })}
onSaveDetails={(details) =>
onDraftChange({
serviceNotes: details.serviceNotes,
extraServices: details.extraServices,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Vendor">
<VendorCell
company={draft.company}
tech={draft.tech}
techPhone={draft.techPhone}
vendors={vendors}
onSave={(patch) =>
onDraftChange({
vendorId: patch.vendorId,
company: patch.company,
tech: patch.tech,
techPhone: patch.techPhone,
})
}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Status">
<StatusCell
wo={draft}
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
onOpenSlideOver={() => {}}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Comp Doc">
<DocBadge row={draft} />
</SlideOverInfoRow>
<SlideOverInfoRow label="POC">
<span className="text-[13px]" style={{ color: "var(--color-text-secondary)" }}>
{draft.pocName || "—"}
{draft.pocPhone ? ` · ${draft.pocPhone}` : ""}
</span>
</SlideOverInfoRow>
</div>
);
}

View file

@ -0,0 +1,26 @@
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverInfoTabStatusFieldProps = {
source: WorkOrderTableRow;
};
export function SlideOverInfoTabStatusField({ source }: SlideOverInfoTabStatusFieldProps) {
if (source.isPastDue) {
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
style={{ color: "var(--status-pastdue-text)" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
Past Due
</span>
);
}
if (source.status) {
return <WoTableStatusLabel status={source.status} />;
}
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}

View file

@ -0,0 +1,45 @@
import { useState } from "react";
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type SlideOverInfoTabVendorFieldProps = {
source: WorkOrderTableRow;
vendors: VendorDropdownItem[];
};
export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) {
const [vendorDlg, setVendorDlg] = useState(false);
if (!source.tech && !source.company) {
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
}
return (
<>
<button
type="button"
onClick={() => setVendorDlg(true)}
className="text-left transition-opacity hover:opacity-80"
>
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
{source.tech || source.company}
</div>
{Boolean(source.tech && source.company) && (
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
{source.company}
</div>
)}
</button>
<VendorDialog
open={vendorDlg}
onOpenChange={setVendorDlg}
company={source.company}
tech={source.tech}
techPhone={source.techPhone}
vendors={vendors}
onSave={() => setVendorDlg(false)}
/>
</>
);
}

View file

@ -0,0 +1,64 @@
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
type SlideOverInfoTabProps = {
row: WorkOrderTableRow;
draft: WorkOrderTableRow;
editing: boolean;
isCancelled: boolean;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
export function SlideOverInfoTab({
row,
draft,
editing,
isCancelled,
users,
sites,
vendors,
pmTypes,
allOrders,
onDraftChange,
onDuplicateFound,
}: SlideOverInfoTabProps) {
const isEditing = editing && !isCancelled;
const source = isEditing ? draft : row;
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
if (isEditing) {
return (
<SlideOverInfoTabEditView
draft={draft}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDraftChange={onDraftChange}
onDuplicateFound={onDuplicateFound}
/>
);
}
return (
<SlideOverInfoTabDisplayView
source={source}
isCancelled={isCancelled}
dispatcher={dispatcher}
vendors={vendors}
/>
);
}

View file

@ -0,0 +1,162 @@
import { useEffect, useMemo, useState } from "react";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import {
buildSlideOverPatch,
isSlideOverDraftDirty,
} from "@/domain/work-orders/utils/slide-over-draft";
import type { SlideOverSaveOptions } from "./work-order-slide-over";
import type { SlideOverTab } from "./use-work-order-slide-over";
type UseSlideOverEditStateArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
infoSource: WorkOrderTableRow | null | undefined;
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
closeDisabled: boolean;
setTab: (tab: SlideOverTab) => void;
};
export function useSlideOverEditState({
row,
editMode,
infoSource,
onSave,
onClose,
closeDisabled,
setTab,
}: UseSlideOverEditStateArgs) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
const [showUnsaved, setShowUnsaved] = useState(false);
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
// not on every optimistic parent-row field update during a failed/in-flight save.
useEffect(() => {
if (!row) {
setDraft(null);
setBaseline(null);
setEditing(false);
setShowUnsaved(false);
setUnsavedIntent(null);
return;
}
setDraft(row);
setBaseline(row);
setEditing(editMode && (row.status as string) !== "Canceled");
setTab("info");
setShowUnsaved(false);
setUnsavedIntent(null);
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
}, [row?.id, editMode]);
useEffect(() => {
if (!infoSource) return;
if (!editing) {
setDraft(infoSource);
setBaseline(infoSource);
return;
}
// While editing, freeze baseline so optimistic parent-row patches cannot clear dirty.
setDraft((prev) => {
if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev;
return infoSource;
});
}, [infoSource, editing, baseline]);
const activeDraft = draft ?? infoSource;
const dirtyBaseline = baseline ?? infoSource;
const isDirty = useMemo(() => {
if (!dirtyBaseline || !activeDraft || !editing) return false;
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
}, [activeDraft, dirtyBaseline, editing]);
const handleDraftChange = (patch: Partial<WorkOrderTableRow>) => {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
};
const commit = () => {
if (!row || !activeDraft || !dirtyBaseline) return;
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
if (Object.keys(patch).length === 0) {
setEditing(false);
setShowUnsaved(false);
return;
}
onSave(row.id, patch, {
onSuccess: () => {
setBaseline(activeDraft);
setEditing(false);
setShowUnsaved(false);
},
});
};
const discard = () => {
if (!dirtyBaseline) return;
setDraft(dirtyBaseline);
setEditing(false);
setShowUnsaved(false);
};
const requestClose = () => {
if (closeDisabled) return;
if (editing && isDirty) {
setUnsavedIntent("close");
setShowUnsaved(true);
return;
}
onClose();
};
const handleCancelEdit = () => {
if (isDirty) {
setUnsavedIntent("cancel-edit");
setShowUnsaved(true);
return;
}
if (dirtyBaseline) setDraft(dirtyBaseline);
setEditing(false);
};
const handleDiscard = () => {
const intent = unsavedIntent;
discard();
setUnsavedIntent(null);
// Close only when Discard was opened from panel close — never from cancel-edit.
// Skip when uploads block close (closeDisabled).
if (intent === "close" && !closeDisabled) {
onClose();
}
};
const startEditing = () => {
if (!infoSource) return;
setDraft(infoSource);
setBaseline(infoSource);
setEditing(true);
setTab("info");
};
return {
editing,
activeDraft,
isDirty,
showUnsaved,
setShowUnsaved,
handleDraftChange,
commit,
discard,
handleDiscard,
requestClose,
handleCancelEdit,
startEditing,
};
}

View file

@ -0,0 +1,97 @@
import { useMemo, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import type {
useDeleteWorkOrderMedia,
useUpdateWorkOrderMediaCategory,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
type UseSlideOverMediaStateArgs = {
detailForRow?: WorkOrderBoardDetail;
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
};
export function useSlideOverMediaState({
detailForRow,
uploadMediaMutation,
deleteMediaMutation,
categorizeMediaMutation,
}: UseSlideOverMediaStateArgs) {
const [mediaBatchPending, setMediaBatchPending] = useState(false);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
const completionMedia = useMemo(
() =>
uploads.filter(
(item) =>
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
item.uploadStatus === "uploading" ||
item.uploadStatus === "failed",
),
[uploads],
);
const extrasUploads = useMemo(
() =>
boardMediaItemsToMediaFiles(
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
),
[detailForRow?.media],
);
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
const mediaPending =
mediaBatchPending ||
hasLocalUploading ||
uploadMediaMutation.isPending ||
deleteMediaMutation.isPending ||
categorizeMediaMutation.isPending;
const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
startUploads(requests);
};
const uploadExtraFiles = async (files: File[]) => {
setMediaBatchPending(true);
try {
for (const file of files) {
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
}
} finally {
setMediaBatchPending(false);
}
};
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
categorizeMediaMutation.mutate({ mediaId, category });
};
const removeMedia = (id: string) => {
if (isLocal(id)) {
removeLocal(id);
return;
}
deleteMediaMutation.mutate(id);
};
return {
completionMedia,
extrasUploads,
mediaPending,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
retryUpload,
cancelUpload,
removeMedia,
};
}

View file

@ -0,0 +1,151 @@
import { useState } from "react";
import { toast } from "react-toastify";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useDeleteWorkOrderMedia,
useUpdateWorkOrderComment,
useUpdateWorkOrderMediaCategory,
useUploadCompletionDoc,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-state";
import type { SlideOverSaveOptions } from "./work-order-slide-over";
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
type UseWorkOrderSlideOverArgs = {
row: WorkOrderTableRow | null;
editMode: boolean;
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onClose: () => void;
saving?: boolean;
};
export function useWorkOrderSlideOver({
row,
editMode,
onSave,
onClose,
saving,
}: UseWorkOrderSlideOverArgs) {
const { user } = useAuthContext();
const workOrderId = row?.id;
const {
data: detail,
isLoading,
isFetching,
error,
refetch,
} = useWorkOrderBoardDetail(workOrderId, Boolean(row));
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
const categorizeMediaMutation = useUpdateWorkOrderMediaCategory(workOrderId ?? "");
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
const [tab, setTab] = useState<SlideOverTab>("info");
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
const detailForRow =
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
const infoSource = detailForRow?.info ?? row;
const isCancelled = (infoSource?.status as string) === "Canceled";
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
const {
completionMedia,
extrasUploads,
mediaPending,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
retryUpload,
cancelUpload,
removeMedia,
} = useSlideOverMediaState({
detailForRow,
uploadMediaMutation,
deleteMediaMutation,
categorizeMediaMutation,
});
const completionPending = uploadCompletionMutation.isPending;
const closeDisabled = Boolean(saving || mediaPending || completionPending);
const editState = useSlideOverEditState({
row,
editMode,
infoSource,
onSave,
onClose,
closeDisabled,
setTab,
});
const uploadCompletionPdf = (file: File) => {
const version = infoSource?.rowVersion;
if (!version || !row || !infoSource) {
toast.error("Missing work order version. Refresh and try again.");
return;
}
uploadCompletionMutation.mutate(
{ file, workOrderVersion: version },
{
onSuccess: () => {
// Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) {
patch.completedDate = todayIso();
}
onSave(row.id, patch);
},
},
);
};
const showDetailSkeleton = isLoading && !detailForRow;
const errorMessage =
error instanceof Error ? error.message : "Failed to load work order details.";
return {
user,
detail,
isLoading,
isFetching,
error,
refetch,
commentMutation,
updateCommentMutation,
tab,
setTab,
detailForRow,
infoSource,
isCancelled,
isDocumentsReadOnly,
completionMedia,
extrasUploads,
mediaPending,
completionPending,
closeDisabled,
uploadMediaRequests,
uploadExtraFiles,
categorizeMedia,
retryUpload,
cancelUpload,
removeMedia,
deleteMediaMutation,
uploadCompletionPdf,
showDetailSkeleton,
errorMessage,
...editState,
};
}

View file

@ -0,0 +1,101 @@
import { useMemo, useState } from "react";
import { Box, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
import { CommentTimeline } from "@/components/common/comment-timeline";
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
import { Button } from "@/components/ui/button";
import { AuditTimeline } from "./audit-timeline";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
type ActivityTab = "customer" | "internal" | "audit";
type WorkOrderActivitySectionProps = {
wo: WorkOrderDetail;
onCommentSubmit: (
text: string,
commentType: "customer" | "internal",
options?: { onSuccess?: () => void },
) => void;
isCommentPending: boolean;
};
export function WorkOrderActivitySection({
wo,
onCommentSubmit,
isCommentPending,
}: WorkOrderActivitySectionProps) {
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const customerComments = useMemo(
() => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo.comments],
);
const internalComments = useMemo(
() => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"),
[wo.comments],
);
const auditLog = wo.auditLog ?? [];
const handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal", {
onSuccess: () => setCommentText(""),
});
};
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Activity
</Typography>
<Tabs
value={activeTab}
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
className="mb-3"
>
<Tab value="customer" label={`Customer (${customerComments.length})`} />
<Tab value="internal" label={`Internal (${internalComments.length})`} />
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs>
{activeTab === "customer" && (
<CommentTimeline
items={mapWorkOrderCommentsToTimeline(customerComments)}
emptyMessage="No comments yet."
/>
)}
{activeTab === "internal" && (
<CommentTimeline
items={mapWorkOrderCommentsToTimeline(internalComments)}
emptyMessage="No comments yet."
/>
)}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
{activeTab === "internal" && (
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
<TextField
multiline
rows={2}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Type a comment..."
disabled={isCommentPending}
/>
<Button
type="submit"
className="self-end"
disabled={!commentText.trim()}
loading={isCommentPending}
loadingText="Sending…"
>
Send
</Button>
</Box>
)}
</Paper>
);
}

View file

@ -0,0 +1,56 @@
import { Button, Menu, MenuItem } from "@mui/material";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderAssigneeMenuProps = {
assignedTo: string;
users: WorkOrderUser[];
disabled?: boolean;
onAssign: (userId: string | number, userName: string) => void;
};
export function WorkOrderAssigneeMenu({
assignedTo,
users,
disabled = false,
onAssign,
}: WorkOrderAssigneeMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
return (
<>
<Button
size="small"
disabled={disabled}
aria-busy={disabled || undefined}
onClick={(event) => setAnchor(event.currentTarget)}
>
{disabled ? "Updating…" : assignedTo || "Unassigned"}
</Button>
<Menu anchorEl={anchorEl} open={open} onClose={close}>
<MenuItem
disabled={disabled}
onClick={() => {
close();
onAssign("", "");
}}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={assignedTo === user.name}
disabled={disabled}
onClick={() => {
close();
onAssign(user.id, user.name);
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</>
);
}

View file

@ -0,0 +1,20 @@
import { Accordion, AccordionDetails, AccordionSummary, Typography } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
type WorkOrderDescriptionAccordionProps = {
wo: WorkOrderDetail;
};
export function WorkOrderDescriptionAccordion({ wo }: WorkOrderDescriptionAccordionProps) {
return (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
);
}

View file

@ -0,0 +1,49 @@
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { WorkOrderActivitySection } from "./work-order-activity-section";
import { WorkOrderDescriptionAccordion } from "./work-order-description-accordion";
import { WorkOrderDetailsCard } from "./work-order-details-card";
import { WorkOrderDispatchesAccordion } from "./work-order-dispatches-accordion";
import { WorkOrderSimpleAccordions } from "./work-order-simple-accordions";
type WorkOrderDetailsSectionProps = {
wo: WorkOrderDetail;
users: WorkOrderUser[];
onAssign: (userId: string | number, userName: string) => void;
isAssignPending?: boolean;
onDispatchClick: (dispatchId: string | number) => void;
onCommentSubmit: (
text: string,
commentType: "customer" | "internal",
options?: { onSuccess?: () => void },
) => void;
isCommentPending: boolean;
};
export function WorkOrderDetailsSection({
wo,
users,
onAssign,
isAssignPending = false,
onDispatchClick,
onCommentSubmit,
isCommentPending,
}: WorkOrderDetailsSectionProps) {
return (
<>
<WorkOrderDetailsCard
wo={wo}
users={users}
onAssign={onAssign}
isAssignPending={isAssignPending}
/>
<WorkOrderSimpleAccordions wo={wo} />
<WorkOrderDispatchesAccordion wo={wo} onDispatchClick={onDispatchClick} />
<WorkOrderDescriptionAccordion wo={wo} />
<WorkOrderActivitySection
wo={wo}
onCommentSubmit={onCommentSubmit}
isCommentPending={isCommentPending}
/>
</>
);
}

View file

@ -0,0 +1,45 @@
import { Box, Paper, Typography } from "@mui/material";
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
type WorkOrderDetailsCardProps = {
wo: WorkOrderDetail;
users: WorkOrderUser[];
onAssign: (userId: string | number, userName: string) => void;
isAssignPending?: boolean;
};
export function WorkOrderDetailsCard({
wo,
users,
onAssign,
isAssignPending = false,
}: WorkOrderDetailsCardProps) {
return (
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Details
</Typography>
<DetailFieldGrid>
<DetailField label="Internal WO" value={wo.internalWONumber} />
<DetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
<DetailField label="Priority" value={wo.priority || "—"} />
<DetailField label="Location" value={wo.location || "—"} />
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
Assigned To
</Typography>
<WorkOrderAssigneeMenu
assignedTo={wo.assignedTo}
users={users}
disabled={isAssignPending}
onAssign={onAssign}
/>
</Box>
<DetailField label="Source" value={wo.source || "—"} />
<DetailField label="TT" value={wo.tt || "—"} />
</DetailFieldGrid>
</Paper>
);
}

View file

@ -0,0 +1,85 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
type WorkOrderDispatchesAccordionProps = {
wo: WorkOrderDetail;
onDispatchClick: (dispatchId: string | number) => void;
};
export function WorkOrderDispatchesAccordion({
wo,
onDispatchClick,
}: WorkOrderDispatchesAccordionProps) {
return (
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Dispatches ({wo.dispatches.length})</Typography>
</AccordionSummary>
<AccordionDetails>
{wo.dispatches.length ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Dispatch #</TableCell>
<TableCell>Vendor</TableCell>
<TableCell>NTE</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
</TableRow>
</TableHead>
<TableBody>
{wo.dispatches.map((dispatch) => {
const color = waitTimeColor(dispatch.dispatchedAt);
return (
<TableRow
key={String(dispatch.id)}
hover
className="cursor-pointer"
onClick={() => onDispatchClick(dispatch.id)}
>
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
<TableCell>{dispatch.vendorName}</TableCell>
<TableCell>
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
</TableCell>
<TableCell>{dispatch.status}</TableCell>
<TableCell>
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
</TableCell>
<TableCell>
{dispatch.status === "Sent" ? (
<Typography variant="caption" className={getWaitTimeTextClass(color)}>
{timeSince(dispatch.dispatchedAt)}
</Typography>
) : (
"—"
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography color="text.secondary">No dispatches yet.</Typography>
)}
</AccordionDetails>
</Accordion>
);
}

View file

@ -0,0 +1,61 @@
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { formatDateTime } from "@/lib/time-utils";
type WorkOrderSimpleAccordionsProps = {
wo: WorkOrderDetail;
};
export function WorkOrderSimpleAccordions({ wo }: WorkOrderSimpleAccordionsProps) {
return (
<>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Classification</Typography>
</AccordionSummary>
<AccordionDetails>
<DetailFieldGrid>
<DetailField label="Problem" value={wo.problem || "—"} />
<DetailField label="Trade" value={wo.trade || "—"} />
<DetailField label="Sub-Trade" value={wo.subTrade || "—"} />
</DetailFieldGrid>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Schedule</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<DetailField
label="Created"
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
/>
<DetailField label="Due Date" value={wo.dueDate || "—"} />
<DetailField label="Scheduled" value={wo.scheduledDate || "—"} />
<DetailField label="Completed" value={wo.completedDate || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>Financial</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<DetailField
label="Vendor NTE"
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
/>
<DetailField label="Customer PO" value={wo.po || "—"} />
<DetailField label="Customer NTE" value="Not connected" />
</Box>
</AccordionDetails>
</Accordion>
</>
);
}

View file

@ -0,0 +1,52 @@
import type { SlideOverTab } from "./use-work-order-slide-over";
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
{ id: "info", label: "Info" },
{ id: "comments", label: "Comments" },
{ id: "audit", label: "Audit Log" },
{ id: "completion", label: "Completion Doc" },
{ id: "extras", label: "Extra Docs" },
];
type WorkOrderSlideOverTabNavProps = {
tab: SlideOverTab;
saving?: boolean;
onTabChange: (tab: SlideOverTab) => void;
};
export function WorkOrderSlideOverTabNav({
tab,
saving,
onTabChange,
}: WorkOrderSlideOverTabNavProps) {
return (
<div
className="mt-2 flex shrink-0 gap-4 overflow-x-auto px-4"
style={{ borderBottom: "1px solid var(--color-border)" }}
role="tablist"
aria-label="Work order sections"
>
{SLIDE_OVER_TABS.map((item) => {
const active = tab === item.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={active}
disabled={Boolean(saving)}
onClick={() => onTabChange(item.id)}
className="shrink-0 border-b-2 pb-2.5 text-[12px] transition-colors disabled:opacity-50"
style={{
borderBottomColor: active ? "var(--color-primary)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-muted)",
fontWeight: active ? 500 : 400,
}}
>
{item.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,33 @@
import type { ReactNode } from "react";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton";
type SlideOverTabSkeletonVariant = "comments" | "audit" | "completion" | "extras";
type WorkOrderSlideOverTabPanelProps = {
variant: SlideOverTabSkeletonVariant;
showSkeleton: boolean;
hasError: boolean;
errorMessage: string;
onRetry: () => void;
showContent: boolean;
children: ReactNode;
};
export function WorkOrderSlideOverTabPanel({
variant,
showSkeleton,
hasError,
errorMessage,
onRetry,
showContent,
children,
}: WorkOrderSlideOverTabPanelProps) {
return (
<>
{showSkeleton && <SlideOverTabSkeleton variant={variant} />}
{hasError && <QueryErrorPanel compact message={errorMessage} onRetry={onRetry} />}
{showContent && children}
</>
);
}

View file

@ -0,0 +1,156 @@
import { Box } from "@mui/material";
import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab";
import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-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 { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
type WorkOrderSlideOverTabsProps = {
tab: SlideOverTab;
state: SlideOverState;
saving?: boolean;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onOpenCompletionDoc: () => void;
};
export function WorkOrderSlideOverTabs({
tab,
state,
saving,
users,
sites,
vendors,
pmTypes,
allOrders,
onDuplicateFound,
onOpenCompletionDoc,
}: WorkOrderSlideOverTabsProps) {
const hasError = Boolean(state.error) && !state.detailForRow;
const showContent = Boolean(state.detailForRow || !state.isLoading);
const { infoSource, activeDraft } = state;
return (
<Box className="flex-1 overflow-y-auto p-5">
{tab === "info" && infoSource != null && activeDraft != null && (
<SlideOverInfoTab
row={infoSource}
draft={activeDraft}
editing={state.editing && !saving}
isCancelled={state.isCancelled}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDraftChange={state.handleDraftChange}
onDuplicateFound={onDuplicateFound}
/>
)}
{tab === "comments" && (
<WorkOrderSlideOverTabPanel
variant="comments"
showSkeleton={state.showDetailSkeleton}
hasError={hasError}
errorMessage={state.errorMessage}
onRetry={() => void state.refetch()}
showContent={state.detailForRow != null}
>
{state.detailForRow != null && (
<SlideOverCommentsTab
comments={state.detailForRow.comments}
isReadOnly={state.isDocumentsReadOnly}
isPending={state.commentMutation.isPending}
currentUserId={state.user?.id}
isUpdatePending={state.updateCommentMutation.isPending}
onSubmit={(text, options) => state.commentMutation.mutate({ text }, options)}
onUpdateComment={(commentId, text, options) =>
state.updateCommentMutation.mutate({ commentId, text }, options)
}
/>
)}
</WorkOrderSlideOverTabPanel>
)}
{tab === "audit" && (
<WorkOrderSlideOverTabPanel
variant="audit"
showSkeleton={state.showDetailSkeleton}
hasError={hasError}
errorMessage={state.errorMessage}
onRetry={() => void state.refetch()}
showContent={state.detailForRow != null}
>
{state.detailForRow != null && (
<SlideOverAuditTab entries={state.detailForRow.audit} users={users} />
)}
</WorkOrderSlideOverTabPanel>
)}
{tab === "completion" && state.infoSource != null && (
<WorkOrderSlideOverTabPanel
variant="completion"
showSkeleton={state.showDetailSkeleton}
hasError={hasError}
errorMessage={state.errorMessage}
onRetry={() => void state.refetch()}
showContent={showContent}
>
<SlideOverCompletionTab
row={state.infoSource}
isReadOnly={state.isDocumentsReadOnly}
completion={state.detailForRow?.completion}
media={state.completionMedia}
mediaPending={state.mediaPending}
completionPending={state.completionPending}
onUploadMedia={(requests) => {
state.uploadMediaRequests(requests);
}}
onCategorizeMedia={state.categorizeMedia}
onRemoveMedia={state.removeMedia}
onRetryMedia={state.retryUpload}
onCancelMedia={state.cancelUpload}
onUploadCompletionPdf={state.uploadCompletionPdf}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
</WorkOrderSlideOverTabPanel>
)}
{tab === "extras" && (
<WorkOrderSlideOverTabPanel
variant="extras"
showSkeleton={state.showDetailSkeleton}
hasError={hasError}
errorMessage={state.errorMessage}
onRetry={() => void state.refetch()}
showContent={showContent}
>
<SlideOverExtrasTab
uploads={state.extrasUploads}
isReadOnly={state.isDocumentsReadOnly}
pending={state.mediaPending}
onUpload={(files) => {
void state.uploadExtraFiles(files);
}}
onRemove={(id) => state.deleteMediaMutation.mutate(id)}
/>
</WorkOrderSlideOverTabPanel>
)}
</Box>
);
}

View file

@ -0,0 +1,108 @@
import { Box } from "@mui/material";
import { SideSheet } from "@/components/ui/side-sheet";
import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
import type {
LocationOption,
VendorDropdownItem,
WorkOrderUser,
} from "@/domain/work-orders/types/work-order";
import type {
WorkOrderTablePatch,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
export type SlideOverSaveOptions = {
onSuccess?: () => void;
onError?: (error: Error) => void;
};
type WorkOrderSlideOverProps = {
row: WorkOrderTableRow | null;
editMode: boolean;
users: WorkOrderUser[];
sites: LocationOption[];
vendors: VendorDropdownItem[];
pmTypes: string[];
allOrders: WorkOrderTableRow[];
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
onCancelWO: (row: WorkOrderTableRow) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
onClose: () => void;
saving?: boolean;
};
export function WorkOrderSlideOver({
row,
editMode,
users,
sites,
vendors,
pmTypes,
allOrders,
onSave,
onOpenCompletionDoc,
onCancelWO,
onDuplicateFound,
onClose,
saving,
}: WorkOrderSlideOverProps) {
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
const { infoSource, activeDraft } = state;
return (
<SideSheet
open={Boolean(row)}
onClose={state.requestClose}
closeDisabled={state.closeDisabled}
widthClass="w-[480px] max-w-[480px]"
aria-label="Work order details"
>
{row != null && infoSource != null && activeDraft != null && (
<Box className="flex h-full flex-col overflow-hidden">
<SlideOverHeader
row={infoSource}
isCancelled={state.isCancelled}
isFetching={state.isFetching && !state.isLoading}
closeDisabled={state.closeDisabled}
onClose={state.requestClose}
/>
<WorkOrderSlideOverTabNav tab={state.tab} saving={saving} onTabChange={state.setTab} />
<WorkOrderSlideOverTabs
tab={state.tab}
state={state}
saving={saving}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
allOrders={allOrders}
onDuplicateFound={onDuplicateFound}
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
/>
<SlideOverFooter
isCancelled={state.isCancelled}
editing={state.editing}
isDirty={state.isDirty}
showUnsaved={state.showUnsaved}
saving={saving}
onCancelWO={() => onCancelWO(infoSource)}
onCancelEdit={state.handleCancelEdit}
onClose={state.requestClose}
onEdit={state.startEditing}
onSave={state.commit}
onDiscard={state.handleDiscard}
onKeepEditing={() => state.setShowUnsaved(false)}
/>
</Box>
)}
</SideSheet>
);
}

View file

@ -0,0 +1,50 @@
import { Menu, MenuItem } from "@mui/material";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WorkOrderStatusMenuProps = {
status: string;
disabled?: boolean;
onStatusChange: (status: string) => void;
};
export function WorkOrderStatusMenu({
status,
disabled = false,
onStatusChange,
}: WorkOrderStatusMenuProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
return (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
disabled={disabled}
aria-label="Change status"
aria-busy={disabled || undefined}
>
<WorkOrderStatusBadge status={status} />
</MenuAnchorButton>
<Menu anchorEl={anchorEl} open={open} onClose={close}>
{WORK_ORDER_STATUS_OPTIONS.map((option) => (
<MenuItem
key={option}
selected={option === status}
disabled={disabled}
onClick={() => {
close();
if (option !== status) {
onStatusChange(option);
}
}}
>
{option}
</MenuItem>
))}
</Menu>
</>
);
}

View file

@ -0,0 +1,50 @@
import {
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from "@mui/material";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type WorkOrderCancelDialogProps = {
row: WorkOrderTableRow | null;
isPending: boolean;
onDialogClose: (() => void) | undefined;
onKeepWO: () => void;
onConfirm: () => void;
};
export function WorkOrderCancelDialog({
row,
isPending,
onDialogClose,
onKeepWO,
onConfirm,
}: WorkOrderCancelDialogProps) {
return (
<Dialog open={Boolean(row)} onClose={onDialogClose} slotProps={dialogBlurBackdropSlotProps}>
<DialogTitle>Cancel work order?</DialogTitle>
<DialogContent>
<DialogContentText>
Cancel WO #{row?.woNumber}? The record will be kept with status Canceled.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button variant="outline" onClick={onKeepWO} disabled={isPending}>
Keep WO
</Button>
<Button
variant="destructive"
onClick={onConfirm}
loading={isPending}
loadingText="Canceling…"
>
Cancel WO
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -1,32 +1,45 @@
import { type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Dialog, DialogContent, DialogTitle, Skeleton, Stack } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { LoadingRegion } from "@/components/ui/loading-region";
import { Text } from "@/components/ui/text";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { MediaUploader } from "./media-uploader";
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useDeleteWorkOrderMedia,
useUpdateWorkOrderMediaCategory,
useUploadWorkOrderMedia,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { guardDialogClose } from "@/lib/dialog-guard";
type MediaUploadModalProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
};
export function MediaUploadModal({
row,
open,
onOpenChange,
uploads,
onChange,
}: MediaUploadModalProps) {
export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalProps) {
const { data: detail, isLoading } = useWorkOrderBoardDetail(row.id, open);
const uploadMutation = useUploadWorkOrderMedia(row.id);
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
const deleteMutation = useDeleteWorkOrderMedia(row.id);
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
const pending =
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
const handleClose = guardDialogClose(pending, () => onOpenChange(false));
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -45,11 +58,36 @@ export function MediaUploadModal({
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
</div>
<Text className="sr-only" variant="description">
Upload photos and videos for this work order.
Upload photos and videos for this work order, then assign Before, After, or Extra.
</Text>
<div className="mt-4">
<MediaUploader uploads={uploads} onChange={onChange} />
{isLoading && !detail ? (
<LoadingRegion label="Loading media">
<Stack spacing={1.5}>
<Skeleton variant="rounded" height={100} animation="wave" />
<Skeleton variant="rounded" height={40} animation="wave" />
<Skeleton variant="rounded" height={40} animation="wave" />
</Stack>
</LoadingRegion>
) : (
<MediaUploader
uploads={uploads}
onUpload={startUploads}
onRemove={(id) => {
if (isLocal(id)) {
removeLocal(id);
return;
}
deleteMutation.mutate(id);
}}
onCategorize={(id, category) => categorizeMutation.mutate({ mediaId: id, category })}
onRetry={retryUpload}
onCancel={cancelUpload}
readOnly={isReadOnly}
pending={pending}
/>
)}
</div>
<div className="mt-2 flex justify-end">
@ -57,6 +95,7 @@ export function MediaUploadModal({
variant="outline"
size="default"
className="text-[12px]"
disabled={pending}
onClick={() => onOpenChange(false)}
>
Close

View file

@ -0,0 +1,60 @@
import type { RefObject } from "react";
import { Upload } from "lucide-react";
import {
formatMediaSizeLimit,
workOrderMediaAcceptAttribute,
} from "@/domain/work-orders/utils/work-order-media-limits";
type MediaUploaderDropzoneProps = {
inputRef: RefObject<HTMLInputElement | null>;
pending: boolean;
onFilesSelected: (files: FileList | null) => void;
};
export function MediaUploaderDropzone({
inputRef,
pending,
onFilesSelected,
}: MediaUploaderDropzoneProps) {
return (
<>
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
onFilesSelected(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{pending ? "Uploading…" : "Drag files here or click to browse"}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file · categorize after
upload
</span>
</button>
<input
ref={inputRef}
type="file"
accept={workOrderMediaAcceptAttribute()}
multiple
hidden
disabled={pending}
onChange={(e) => {
onFilesSelected(e.target.files);
e.target.value = "";
}}
/>
</>
);
}

View file

@ -0,0 +1,113 @@
import { RotateCcw, X } from "lucide-react";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { canDeleteWorkOrderMedia } from "@/domain/work-orders/utils/work-order-media-limits";
import { MediaUploaderGridItemPreview } from "./media-uploader-grid-item-preview";
type MediaUploaderGridItemChromeProps = {
file: MediaFile;
readOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
onRetry?: (id: string) => void;
onCancel?: (id: string) => void;
};
export function MediaUploaderGridItemChrome({
file,
readOnly,
pending,
onRemove,
onRetry,
onCancel,
}: MediaUploaderGridItemChromeProps) {
const uploading = file.uploadStatus === "uploading";
const failed = file.uploadStatus === "failed";
const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
return (
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
<MediaUploaderGridItemPreview file={file} />
{Boolean(uploading) && (
<div
className="absolute inset-x-0 bottom-0"
style={{ height: 4, background: "rgba(0,0,0,0.25)" }}
>
<div
style={{
height: "100%",
width: `${file.progress ?? 0}%`,
background: "var(--color-brand-primary, #2563eb)",
}}
/>
</div>
)}
{Boolean(uploading && onCancel) && (
<button
type="button"
onClick={() => onCancel?.(file.id)}
title="Cancel upload"
className="absolute flex items-center justify-center"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
)}
{Boolean(failed && onRetry) && (
<button
type="button"
onClick={() => onRetry?.(file.id)}
title="Retry upload"
className="absolute flex items-center justify-center"
style={{
top: 4,
left: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<RotateCcw size={11} />
</button>
)}
{Boolean(showDelete && !uploading) && (
<button
type="button"
onClick={() => onRemove(file.id)}
title="Remove"
disabled={pending}
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
)}
</div>
);
}

View file

@ -0,0 +1,67 @@
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
const PURPOSE_OPTIONS: UploadableMediaCategory[] = ["Before", "After", "Extra"];
type MediaUploaderGridItemMetaProps = {
file: MediaFile;
readOnly: boolean;
pending: boolean;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
};
export function MediaUploaderGridItemMeta({
file,
readOnly,
pending,
onCategorize,
}: MediaUploaderGridItemMetaProps) {
const failed = file.uploadStatus === "failed";
const needsCategory =
!readOnly &&
!file.isLegacy &&
file.uploadStatus !== "uploading" &&
file.uploadStatus !== "failed" &&
Boolean(onCategorize);
return (
<div className="space-y-1 px-1 py-1">
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
{file.name}
</div>
{Boolean(failed) && (
<div style={{ fontSize: 10, color: "var(--color-danger, #b91c1c)" }}>
{file.errorMessage || "Upload failed"}
</div>
)}
{needsCategory ? (
<select
aria-label={`Category for ${file.name}`}
value={file.purpose ?? ""}
disabled={pending}
onChange={(event) => {
const value = event.target.value as UploadableMediaCategory;
if (value) onCategorize?.(file.id, value);
}}
className="w-full rounded border px-1 py-0.5 text-[10px]"
style={{
borderColor: "var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
>
<option value="">Assign purpose…</option>
{PURPOSE_OPTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
) : (
<div style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
{file.purpose || (file.isLegacy ? "Legacy" : "")}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,52 @@
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Play,
} from "lucide-react";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
};
type MediaUploaderGridItemPreviewProps = {
file: MediaFile;
};
export function MediaUploaderGridItemPreview({ file }: MediaUploaderGridItemPreviewProps) {
const TypeIcon = FILETYPE_ICON[file.fileType];
if (file.fileType === "image") {
return (
<img
src={file.url}
alt={file.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
);
}
if (file.fileType === "video") {
return (
<>
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
);
}
return (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
);
}

View file

@ -0,0 +1,46 @@
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { MediaUploaderGridItemChrome } from "./media-uploader-grid-item-chrome";
import { MediaUploaderGridItemMeta } from "./media-uploader-grid-item-meta";
type MediaUploaderGridItemProps = {
file: MediaFile;
readOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
onRetry?: (id: string) => void;
onCancel?: (id: string) => void;
};
export function MediaUploaderGridItem({
file,
readOnly,
pending,
onRemove,
onCategorize,
onRetry,
onCancel,
}: MediaUploaderGridItemProps) {
return (
<div
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<MediaUploaderGridItemChrome
file={file}
readOnly={readOnly}
pending={pending}
onRemove={onRemove}
onRetry={onRetry}
onCancel={onCancel}
/>
<MediaUploaderGridItemMeta
file={file}
readOnly={readOnly}
pending={pending}
onCategorize={onCategorize}
/>
</div>
);
}

View file

@ -0,0 +1,42 @@
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import { MediaUploaderGridItem } from "./media-uploader-grid-item";
type MediaUploaderGridProps = {
uploads: MediaFile[];
readOnly: boolean;
pending: boolean;
onRemove: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
onRetry?: (id: string) => void;
onCancel?: (id: string) => void;
};
export function MediaUploaderGrid({
uploads,
readOnly,
pending,
onRemove,
onCategorize,
onRetry,
onCancel,
}: MediaUploaderGridProps) {
if (uploads.length === 0) return null;
return (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((file) => (
<MediaUploaderGridItem
key={file.id}
file={file}
readOnly={readOnly}
pending={pending}
onRemove={onRemove}
onCategorize={onCategorize}
onRetry={onRetry}
onCancel={onCancel}
/>
))}
</div>
);
}

View file

@ -1,143 +1,107 @@
import { useRef } from "react";
import { toast } from "react-toastify";
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Play,
Upload,
X,
} from "lucide-react";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
canDeleteWorkOrderMedia,
formatMediaSizeLimit,
isWorkOrderFileTooLarge,
isWorkOrderMediaTypeAllowed,
} from "@/domain/work-orders/utils/work-order-media-limits";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
import { MediaUploaderGrid } from "./media-uploader-grid";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
export type MediaUploadRequest = {
localId: string;
file: File;
onProgress: (percent: number) => void;
};
type MediaUploaderProps = {
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
/** Upload-then-categorize: files only — category assigned after persist. */
onUpload?: (requests: MediaUploadRequest[]) => void;
onRemove?: (id: string) => void;
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
onRetry?: (localId: string) => void;
onCancel?: (localId: string) => void;
/** Legacy local-only mode (table modal fallback). */
onChange?: (next: MediaFile[]) => void;
readOnly?: boolean;
pending?: boolean;
};
export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
export function MediaUploader({
uploads,
onUpload,
onRemove,
onCategorize,
onRetry,
onCancel,
onChange,
readOnly = false,
pending = false,
}: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const addFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
if (readOnly || pending || !files || files.length === 0) return;
const accepted: File[] = [];
for (const file of Array.from(files)) {
if (!isWorkOrderMediaTypeAllowed(file)) {
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
continue;
}
if (isWorkOrderFileTooLarge(file)) {
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
continue;
}
accepted.push(file);
}
if (accepted.length === 0) return;
if (onUpload) {
const requests: MediaUploadRequest[] = accepted.map((file) => ({
localId: `local-${crypto.randomUUID()}`,
file,
onProgress: () => undefined,
}));
onUpload(requests);
return;
}
if (onChange) {
onChange([...uploads, ...accepted.map(makeMediaFile)]);
}
};
const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
const remove = (id: string) => {
if (readOnly || pending) return;
const target = uploads.find((u) => u.id === id);
if (target && !canDeleteWorkOrderMedia(target) && target.uploadStatus !== "failed") return;
if (onRemove) {
onRemove(id);
return;
}
onChange?.(uploads.filter((u) => u.id !== id));
};
return (
<div>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
Drag files here or click to browse
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
onChange={(e) => addFiles(e.target.files)}
/>
{uploads.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((u) => {
const TypeIcon = FILETYPE_ICON[u.fileType];
return (
<div
key={u.id}
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
{u.fileType === "image" ? (
<img
src={u.url}
alt={u.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : u.fileType === "video" ? (
<>
<video
src={u.url}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
) : (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
)}
<button
type="button"
onClick={() => remove(u.id)}
title="Remove"
className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
</div>
<div className="px-1 py-1">
<div
className="truncate"
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
>
{u.name}
</div>
</div>
</div>
);
})}
</div>
{!readOnly && (
<MediaUploaderDropzone inputRef={inputRef} pending={pending} onFilesSelected={addFiles} />
)}
<MediaUploaderGrid
uploads={uploads}
readOnly={readOnly}
pending={pending}
onRemove={remove}
onCategorize={onCategorize}
onRetry={onRetry}
onCancel={onCancel}
/>
</div>
);
}

View file

@ -0,0 +1,199 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils";
type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal };
type UploadMutate = (input: {
file: File;
signal?: AbortSignal;
onProgress?: (percent: number) => void;
}) => Promise<unknown>;
type SessionUploadRequest = {
localId: string;
file: File;
signal: AbortSignal;
};
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
const classified = classifyUpload(request.file);
return {
id: request.localId,
name: request.file.name,
fileType: classified.fileType,
url: URL.createObjectURL(request.file),
category: classified.category,
purpose: null,
progress: 0,
uploadStatus: "uploading" as const,
sourceFile: request.file,
signal: request.signal,
};
}
function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
return item.id === request.localId && item.signal === request.signal;
}
function revokeBlobUrl(url: string) {
if (url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
export function useMediaUploadSession(
detail: WorkOrderBoardDetail | undefined,
uploadMutateAsync: UploadMutate,
) {
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
const controllersRef = useRef(new Map<string, AbortController>());
const workOrderId = detail?.info.id;
const previousWorkOrderIdRef = useRef(workOrderId);
// Drop local tiles and abort in-flight uploads when the bound WO changes.
// Skip the first bind (undefined → id) so an early upload is not cleared.
useEffect(() => {
if (previousWorkOrderIdRef.current === workOrderId) return;
const hadPrevious = previousWorkOrderIdRef.current != null;
previousWorkOrderIdRef.current = workOrderId;
if (!hadPrevious) return;
for (const controller of controllersRef.current.values()) {
controller.abort();
}
controllersRef.current.clear();
setLocalUploads((prev) => {
for (const item of prev) {
revokeBlobUrl(item.url);
}
return [];
});
}, [workOrderId]);
const serverUploads = useMemo(
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
[detail?.media],
);
const uploads = useMemo(() => {
const serverIds = new Set(serverUploads.map((item) => item.id));
const pendingLocals = localUploads.filter(
(item) => item.uploadStatus === "uploading" || item.uploadStatus === "failed",
);
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
}, [serverUploads, localUploads]);
function clearController(localId: string) {
controllersRef.current.delete(localId);
}
function replaceController(localId: string): AbortController {
controllersRef.current.get(localId)?.abort();
const controller = new AbortController();
controllersRef.current.set(localId, controller);
return controller;
}
function startUploads(requests: MediaUploadRequest[]) {
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
localId: request.localId,
file: request.file,
signal: replaceController(request.localId).signal,
}));
setLocalUploads((prev) => {
const next = [...prev];
for (const request of sessionRequests) {
const entry = toLocalUpload(request);
const index = next.findIndex((item) => item.id === request.localId);
if (index >= 0) {
const previous = next[index];
if (previous) {
revokeBlobUrl(previous.url);
}
next[index] = entry;
} else {
next.push(entry);
}
}
return next;
});
for (const request of sessionRequests) {
void uploadMutateAsync({
file: request.file,
signal: request.signal,
onProgress: (percent) => {
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request) ? { ...item, progress: percent } : item,
),
);
},
})
.then(() => {
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
})
.catch((error: Error) => {
if (error.name === "AbortError") {
clearController(request.localId);
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
return;
}
// Keep controller entry only while retryable; replaceController on retry.
setLocalUploads((prev) =>
prev.map((item) =>
isSameAttempt(item, request)
? {
...item,
uploadStatus: "failed",
errorMessage: error.message || "Upload failed",
progress: undefined,
}
: item,
),
);
});
}
}
function retryUpload(localId: string) {
const target = localUploads.find((item) => item.id === localId);
if (!target?.sourceFile) return;
startUploads([
{
localId,
file: target.sourceFile,
onProgress: () => undefined,
},
]);
}
function cancelUpload(localId: string) {
controllersRef.current.get(localId)?.abort();
clearController(localId);
setLocalUploads((prev) => {
const target = prev.find((item) => item.id === localId);
if (target) {
revokeBlobUrl(target.url);
}
return prev.filter((item) => item.id !== localId);
});
}
function removeLocal(id: string) {
clearController(id);
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
}
function isLocal(id: string) {
return localUploads.some((item) => item.id === id);
}
return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal };
}

View file

@ -0,0 +1,131 @@
import { useMemo, useState } from "react";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
export function useWorkOrdersListPage() {
const [wizardOpen, setWizardOpen] = useState(false);
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
const {
patchField,
patchFieldAsync,
isPending: isPatchPending,
} = useWorkOrderTableMutations({
onPatch: tableData.patchRow,
clearPatch: tableData.clearRowPatch,
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
});
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const pmTypes = useMemo(
() => problemOptions.map((option) => option.value).filter(Boolean),
[problemOptions],
);
const vendorTechs = useMemo(
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
[vendors],
);
const activeSlideOverRow = useMemo(() => {
if (!slideOverRow) return null;
return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow;
}, [slideOverRow, tableData.rows]);
const activeDocRow = useMemo(() => {
if (!docRow) return null;
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
}, [docRow, tableData.rows]);
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
};
const handleCloseSlideOver = () => {
setSlideOverRow(null);
setSlideOverEdit(false);
tableData.setSelectedId(null);
};
const handleConfirmCancel = () => {
if (!confirmCancel) return;
patchField(confirmCancel.id, { status: "Canceled" });
setConfirmCancel(null);
handleCloseSlideOver();
};
const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
const saveCompletionDoc = async (
row: WorkOrderTableRow,
data: CompDocData,
status: CompDocSaveStatus,
) => {
// Persist PM comments + generatedAt in session state (no dedicated BE field yet)
// before patching board docStatus — Save draft / Generate / NN all share this path.
const nextData: CompDocData = {
notes: data.notes,
generatedAt: data.generatedAt,
};
setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData }));
await patchFieldAsync(row.id, { docStatus: status });
};
return {
wizardOpen,
setWizardOpen,
slideOverEdit,
confirmCancel,
setConfirmCancel,
docRow,
setDocRow,
mediaRow,
setMediaRow,
completionDocs,
filters,
tableData,
patchField,
patchFieldAsync,
isPatchPending,
users,
usersLoading,
locations,
vendors,
pmTypes,
vendorTechs,
activeSlideOverRow,
activeDocRow,
activeMediaRow,
handleOpenSlideOver,
handleCloseSlideOver,
handleConfirmCancel,
cancelDialogClose,
saveCompletionDoc,
};
}

View file

@ -1,64 +1,23 @@
import { useMemo, useState } from "react";
import { Box } from "@mui/material";
import { useNavigate } from "react-router";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const navigate = useNavigate();
const { user } = useAuthContext();
const [wizardOpen, setWizardOpen] = useState(false);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
const filters = useWorkOrdersListFilters();
const tableData = useWorkOrdersTableData(filters);
const { patchField } = useWorkOrderTableMutations({
onPatch: tableData.patchRow,
clearPatch: tableData.clearRowPatch,
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
});
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const pmTypes = useMemo(
() => problemOptions.map((option) => option.value).filter(Boolean),
[problemOptions],
);
const vendorTechs = useMemo(
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
[vendors],
);
const totalCount = tableData.totalCount;
const scopeTotalCount = tableData.scopeTotalCount;
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
const page = useWorkOrdersListPage();
const { filters, tableData, activeDocRow } = page;
return (
<Box className="flex h-full flex-col overflow-hidden p-2.5">
@ -71,7 +30,7 @@ export default function WorkOrdersListPage() {
search={filters.search}
onSearchChange={filters.handleSearchChange}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onNewWorkOrder={() => setWizardOpen(true)}
onNewWorkOrder={() => page.setWizardOpen(true)}
activeFilterCount={filters.activeFilterCount}
isFetching={tableData.isFetching && !tableData.isLoading}
/>
@ -85,19 +44,19 @@ export default function WorkOrdersListPage() {
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
onTypeChange={filters.setTypeFilter}
users={users}
usersLoading={usersLoading}
users={page.users}
usersLoading={page.usersLoading}
currentUserId={user?.id}
filteredCount={totalCount}
scopeTotalCount={scopeTotalCount}
filteredCount={tableData.totalCount}
scopeTotalCount={tableData.scopeTotalCount}
/>
{filters.advApplied != null && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={totalCount}
sites={locations}
users={users}
totalCount={tableData.totalCount}
sites={page.locations}
users={page.users}
onClearChip={filters.clearAdvancedFilterChip}
onClearAll={filters.clearAdvancedFilters}
/>
@ -118,47 +77,79 @@ export default function WorkOrdersListPage() {
<WoTable
tableData={tableData}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
users={page.users}
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
onOpenSlideOver={(row, edit) => {
tableData.setSelectedId(row.id);
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
}}
onOpenMedia={setMediaRow}
onOpenSlideOver={page.handleOpenSlideOver}
onOpenCompDoc={page.setDocRow}
onOpenMedia={page.setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onPatch={patchField}
onPatch={page.patchField}
/>
<NewWoWizard open={page.wizardOpen} onOpenChange={page.setWizardOpen} users={page.users} />
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={locations}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
sites={page.locations}
users={page.users}
pmTypes={page.pmTypes}
vendorTechs={page.vendorTechs}
/>
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
<WorkOrderSlideOver
row={page.activeSlideOverRow}
editMode={page.slideOverEdit}
users={page.users}
sites={page.locations}
vendors={page.vendors}
pmTypes={page.pmTypes}
allOrders={tableData.rows}
onSave={(id, patch, options) => page.patchField(id, patch, options)}
onOpenCompletionDoc={page.setDocRow}
onCancelWO={page.setConfirmCancel}
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
saving={page.isPatchPending}
onClose={page.handleCloseSlideOver}
/>
{activeMediaRow != null && (
<WorkOrderCancelDialog
row={page.confirmCancel}
isPending={page.isPatchPending}
onDialogClose={page.cancelDialogClose}
onKeepWO={() => page.setConfirmCancel(null)}
onConfirm={page.handleConfirmCancel}
/>
{page.activeMediaRow != null && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
row={page.activeMediaRow}
open={!!page.activeMediaRow}
onOpenChange={(open) => {
if (!open) setMediaRow(null);
if (!open) page.setMediaRow(null);
}}
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
onChange={(files) => {
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
patchField(activeMediaRow.id, { mediaCount: files.length });
/>
)}
{activeDocRow != null && (
<CompDocDialog
row={activeDocRow}
open={!!activeDocRow}
onOpenChange={(open) => {
if (!open) page.setDocRow(null);
}}
data={page.completionDocs[String(activeDocRow.id)]}
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
onDateCompletedChange={async (date) => {
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
}}
/>
)}

View file

@ -49,17 +49,10 @@ export function PageHeader({
"m-0 font-display mt-1! text-[30px]! font-extrabold leading-[1.1] tracking-[-0.02em] text-foreground",
eyebrow && "mt-1",
)}
sx={{ fontSize: 30, fontWeight: 800, lineHeight: 1.1, letterSpacing: "-0.02em" }}
>
{title}
</Text>
<Text
variant="description"
as="span"
tone="muted"
when={Boolean(subtitle)}
sx={{ mt: 0.75, fontWeight: 500 }}
>
<Text variant="description" tone="muted" when={Boolean(subtitle)}>
{subtitle}
</Text>
</Stack>

View file

@ -101,12 +101,37 @@ export const workOrderBoardDocumentsApi = {
uploadMedia: async (
workOrderId: string | number,
file: File,
category: UploadableMediaCategory,
category?: UploadableMediaCategory,
options?: { signal?: AbortSignal; onProgress?: (percent: number) => void },
): Promise<WorkOrderBoardMediaItem> => {
const formData = buildMediaFormData(file, category);
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
body: formData,
throwHttpErrors: false,
signal: options?.signal,
onUploadProgress: options?.onProgress
? (progress) => {
options.onProgress?.(Math.round(progress.percent * 100));
}
: undefined,
});
const data = await readResponseJson(response);
if (response.ok) {
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
}
throwDocumentHttpError(response, data);
},
updateMediaCategory: async (
workOrderId: string | number,
mediaId: string | number,
category: UploadableMediaCategory,
): Promise<WorkOrderBoardMediaItem> => {
const formData = new FormData();
formData.append("category", category);
const response = await api.patch(API_PATHS.workOrder.mediaItem(workOrderId, mediaId), {
body: formData,
throwHttpErrors: false,
});
const data = await readResponseJson(response);
if (response.ok) {

View file

@ -30,6 +30,7 @@ const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
{ key: "vendorId", field: "vendorId", toValue: (value) => String(value) },
{ key: "apptTime", field: "apptTime", toValue: (value) => value as string },
{ key: "docStatus", field: "docStatus", toValue: (value) => mapDocStatusToApi(value as string) },
{ key: "completedDate", field: "completedDate", toValue: (value) => value as string },
{ key: "pm", field: "pm", toValue: (value) => value as string },
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },

View file

@ -119,9 +119,11 @@ export function mapBoardDetailAuditEntry(raw: unknown, index: number): WorkOrder
export type UploadableMediaCategory = "Before" | "After" | "Extra";
export function buildMediaFormData(file: File, category: UploadableMediaCategory): FormData {
export function buildMediaFormData(file: File, category?: UploadableMediaCategory): FormData {
const form = new FormData();
form.append("category", category);
if (category) {
form.append("category", category);
}
form.append("file", file);
return form;
}
@ -229,7 +231,9 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M
fileType: classifyFileName(item.fileName, item.url),
url: item.url,
category: boardCategoryToMediaCategory(item.category),
purpose: item.category,
isLegacy: item.isLegacy || Number(item.id) <= 0,
uploadStatus: "done" as const,
}));
}

View file

@ -237,6 +237,7 @@ export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
dispatcherId,
dispatcherName,
scheduledOn,
completedDate: toDateInputValue(readString(item, "completedDate", "CompletedDate")),
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
dayGroup,
dayLabel,

View file

@ -116,6 +116,7 @@ export type WorkOrderBoardEditableField =
| "apptDate"
| "apptTime"
| "docStatus"
| "completedDate"
| "pm"
| "serviceNotes"
| "extraServices"

View file

@ -9,8 +9,14 @@ export interface MediaFile {
fileType: FileCategory;
url: string;
category: MediaCategory;
/** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
purpose?: "Before" | "After" | "Extra" | "Completion" | null;
/** True when media is a legacy Before/After/Completion column (not deletable). */
isLegacy?: boolean;
/** 0–100 while uploading; undefined when idle/complete. */
progress?: number;
uploadStatus?: "uploading" | "failed" | "done";
errorMessage?: string;
}
export const MEDIA_CATEGORIES: MediaCategory[] = [

View file

@ -12,6 +12,8 @@ export interface WorkOrderTableRow {
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
/** Work completed date (API CompletedDate); distinct from scheduledOn. */
completedDate: string;
targetWeek: string;
dayGroup: string;
dayLabel: string;

View file

@ -38,7 +38,7 @@ function invalidateBoardDetail(
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
}
interface AddWorkOrderCommentInput {
export interface AddWorkOrderCommentInput {
text: string;
commentType?: "customer" | "internal";
}
@ -50,6 +50,13 @@ export interface UpdateWorkOrderCommentInput {
export interface UploadWorkOrderMediaInput {
file: File;
category?: UploadableMediaCategory;
signal?: AbortSignal;
onProgress?: (percent: number) => void;
}
export interface UpdateWorkOrderMediaCategoryInput {
mediaId: string | number;
category: UploadableMediaCategory;
}
@ -191,12 +198,12 @@ export function useUploadWorkOrderMedia(
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, category }: UploadWorkOrderMediaInput) =>
workOrdersApi.uploadMedia(workOrderId, file, category),
mutationFn: ({ file, category, signal, onProgress }: UploadWorkOrderMediaInput) =>
workOrdersApi.uploadMedia(workOrderId, file, category, { signal, onProgress }),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
toast.success("File uploaded");
toast.success("File uploaded — assign a category");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to upload file");
@ -204,6 +211,25 @@ export function useUploadWorkOrderMedia(
});
}
export function useUpdateWorkOrderMediaCategory(
workOrderId: string | number,
): UseMutationResult<WorkOrderBoardMediaItem, Error, UpdateWorkOrderMediaCategoryInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ mediaId, category }: UpdateWorkOrderMediaCategoryInput) =>
workOrdersApi.updateMediaCategory(workOrderId, mediaId, category),
onSuccess: () => {
invalidateBoardDetail(queryClient, workOrderId);
invalidateWorkOrders(queryClient);
toast.success("Category saved");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update category");
},
});
}
export function useDeleteWorkOrderMedia(
workOrderId: string | number,
): UseMutationResult<void, Error, string | number> {

View file

@ -1,3 +1,4 @@
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
@ -18,6 +19,7 @@ import type {
} from "@/domain/work-orders/types/work-order-table-row";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import { createWorkOrderPatchQueue } from "@/domain/work-orders/utils/work-order-patch-queue";
import { queryKeys } from "@/infra/query-key/query-key";
export type { WorkOrderTablePatch };
@ -28,6 +30,65 @@ export type WorkOrderTablePatchHandlers = {
getRow?: (id: string | number) => WorkOrderTableRow | undefined;
};
type PatchResult =
{ kind: "local"; patch: WorkOrderTablePatch } | { kind: "board"; row: WorkOrderTableRow };
async function applyBoardFieldOps(
id: string | number,
patch: WorkOrderTablePatch,
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
): Promise<WorkOrderTableRow> {
const ops = expandTablePatchToBoardFieldOps(patch);
if (ops.length === 0) {
throw new BoardPatchValidationError(
"NoPersistableFields",
"These changes cannot be saved on the board. Refresh and edit a supported field.",
);
}
const current = getRow?.(id);
if (!current?.rowVersion) {
throw new Error("Missing work order version for board patch. Refresh and retry.");
}
let latest = current;
let completedOps = 0;
for (const op of ops) {
try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
completedOps += 1;
} catch (error) {
if (completedOps > 0) {
const field = op.field;
const partial = new Error(
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
throw partial;
}
throw error;
}
}
return latest;
}
/** Board fields (including completedDate) use PATCH …/board — never legacy EditWorkorder. */
export async function runWorkOrderTablePatch(
id: string | number,
patch: WorkOrderTablePatch,
getRow: ((id: string | number) => WorkOrderTableRow | undefined) | undefined,
): Promise<PatchResult> {
if (isLocalOnlyTablePatch(patch)) {
return { kind: "local", patch };
}
const boardRow = await applyBoardFieldOps(id, patch, getRow);
return { kind: "board", row: boardRow };
}
export function useWorkOrderTableMutations(
onPatchOrHandlers:
((id: string | number, patch: WorkOrderTablePatch) => void) | WorkOrderTablePatchHandlers,
@ -44,53 +105,15 @@ export function useWorkOrderTableMutations(
const { onPatch, clearPatch, getRow } = handlers;
const queryClient = useQueryClient();
const enqueuePatch = useMemo(() => createWorkOrderPatchQueue(), []);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
};
const patchMutation = useMutation({
mutationFn: async ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) => {
if (isLocalOnlyTablePatch(patch)) {
return { kind: "local" as const, patch };
}
const ops = expandTablePatchToBoardFieldOps(patch);
if (ops.length === 0) {
throw new BoardPatchValidationError(
"NoPersistableFields",
"These changes cannot be saved on the board. Refresh and edit a supported field.",
);
}
const current = getRow?.(id);
if (!current?.rowVersion) {
throw new Error("Missing work order version for board patch. Refresh and retry.");
}
let latest = current;
let completedOps = 0;
for (const op of ops) {
try {
latest = await workOrdersApi.patchBoardField(id, buildBoardPatchPayload(op, latest));
completedOps += 1;
} catch (error) {
if (completedOps > 0) {
const field = op.field;
const partial = new Error(
`Schedule update partially saved (${completedOps}/${ops.length} fields). Failed on "${field}": ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
(partial as Error & { partialRow?: WorkOrderTableRow }).partialRow = latest;
throw partial;
}
throw error;
}
}
return { kind: "board" as const, row: latest };
},
mutationFn: ({ id, patch }: { id: string | number; patch: WorkOrderTablePatch }) =>
enqueuePatch(id, () => runWorkOrderTablePatch(id, patch, getRow)),
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
},
@ -123,12 +146,29 @@ export function useWorkOrderTableMutations(
},
});
const patchField = (id: string | number, patch: WorkOrderTablePatch) => {
patchMutation.mutate({ id, patch });
const patchField = (
id: string | number,
patch: WorkOrderTablePatch,
options?: {
onSuccess?: () => void;
onError?: (error: Error) => void;
},
) => {
patchMutation.mutate(
{ id, patch },
{
onSuccess: () => options?.onSuccess?.(),
onError: (error) => options?.onError?.(error),
},
);
};
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
patchMutation.mutateAsync({ id, patch });
return {
patchField,
patchFieldAsync,
isPending: patchMutation.isPending,
};
}

View file

@ -0,0 +1,24 @@
/**
* Runs file uploads one-by-one while holding an aggregate pending flag true
* until every file has settled (success or failure). Avoids relying on a single
* TanStack Mutation `isPending`, which can flip false when a later small file
* finishes before an earlier large one.
*/
export async function runSequentialUploads<TFile>(
files: readonly TFile[],
upload: (file: TFile) => Promise<unknown>,
setPending: (pending: boolean) => void,
): Promise<void> {
if (files.length === 0) {
return;
}
setPending(true);
try {
for (const file of files) {
await upload(file);
}
} finally {
setPending(false);
}
}

View file

@ -23,8 +23,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
"serviceNotes",
"extraServices",
"vendorId",
"company",
"tech",
// company/tech are display companions; board persists vendor via vendorId only
"status",
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;

View file

@ -1,6 +1,10 @@
/** Backend RequestSizeLimit for media and completion-doc uploads. */
export const MAX_WORK_ORDER_MEDIA_BYTES = 30 * 1024 * 1024;
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "video/mp4", "video/quicktime"]);
const ALLOWED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".mp4", ".mov"]);
export function isWorkOrderFileTooLarge(
file: File,
maxBytes = MAX_WORK_ORDER_MEDIA_BYTES,
@ -8,6 +12,19 @@ export function isWorkOrderFileTooLarge(
return file.size > maxBytes;
}
export function isWorkOrderMediaTypeAllowed(file: File): boolean {
const mimeOk = Boolean(file.type) && ALLOWED_MIME_TYPES.has(file.type.toLowerCase());
const name = file.name.toLowerCase();
const dot = name.lastIndexOf(".");
const ext = dot >= 0 ? name.slice(dot) : "";
const extOk = ALLOWED_EXTENSIONS.has(ext);
return mimeOk || extOk;
}
export function workOrderMediaAcceptAttribute(): string {
return "image/jpeg,image/png,video/mp4,video/quicktime,.jpg,.jpeg,.png,.mp4,.mov";
}
export function formatMediaSizeLimit(maxBytes = MAX_WORK_ORDER_MEDIA_BYTES): string {
return `${Math.round(maxBytes / (1024 * 1024))} MB`;
}

View file

@ -0,0 +1,20 @@
/**
* Serializes async work per work-order id so concurrent patches (e.g. a completion-doc
* status save firing alongside a completed-date save) never race two overlapping
* get-then-update round trips against the same record. Each call for a given id waits
* for the previous one to settle before starting its own fetch-and-update cycle.
*/
export function createWorkOrderPatchQueue() {
const chains = new Map<string, Promise<unknown>>();
return function enqueue<T>(id: string | number, run: () => Promise<T>): Promise<T> {
const key = String(id);
const previous = chains.get(key) ?? Promise.resolve();
const settled = previous.catch(() => undefined).then(run);
chains.set(
key,
settled.catch(() => undefined),
);
return settled;
};
}

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