refactor(workorders): meet maintainability gate

This commit is contained in:
Alexandre Brandizzi 2026-09-30 18:43:00 -03:00
parent ffbf60d16a
commit d40d4eaffb
11 changed files with 821 additions and 439 deletions

View file

@ -44,6 +44,45 @@ type SlideOverInfoTabEditViewProps = {
onRequestComplete?: () => void;
};
type SlideOverServiceFieldProps = Pick<
SlideOverInfoTabEditViewProps,
"draft" | "pmTypes" | "services" | "serviceQuery" | "onDraftChange"
>;
function SlideOverServiceField({
draft,
pmTypes,
services = [],
serviceQuery,
onDraftChange,
}: SlideOverServiceFieldProps) {
return (
<SlideOverInfoRow label="Service">
<PMTypeCell
value={draft.pm}
pmTypes={pmTypes}
services={services}
serviceQuery={serviceQuery}
serviceId={draft.serviceId}
workOrderType={draft.type}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
avetaRequired={draft.avetaRequired}
woNumber={draft.woNumber}
onSave={(pm, id) => onDraftChange(id != null ? { pm, serviceId: id } : { pm })}
onSaveDetails={(details) =>
onDraftChange({
...serviceIdentityPatch(draft.serviceId, draft.pm, details.pm, details.serviceId),
serviceNotes: details.serviceNotes,
extraServices: details.extraServices,
avetaRequired: details.avetaRequired,
})
}
/>
</SlideOverInfoRow>
);
}
function SlideOverScheduleOnField({
draft,
onDraftChange,
@ -216,29 +255,13 @@ export function SlideOverInfoTabEditView({
onSave={(apptTime) => onDraftChange({ apptTime })}
/>
</SlideOverInfoRow>
<SlideOverInfoRow label="Service">
<PMTypeCell
value={draft.pm}
pmTypes={pmTypes}
services={services}
serviceQuery={serviceQuery}
serviceId={draft.serviceId}
workOrderType={draft.type}
serviceNotes={draft.serviceNotes}
extraServices={draft.extraServices}
avetaRequired={draft.avetaRequired}
woNumber={draft.woNumber}
onSave={(pm, id) => onDraftChange(id != null ? { pm, serviceId: id } : { pm })}
onSaveDetails={(details) =>
onDraftChange({
...serviceIdentityPatch(draft.serviceId, draft.pm, details.pm, details.serviceId),
serviceNotes: details.serviceNotes,
extraServices: details.extraServices,
avetaRequired: details.avetaRequired,
})
}
/>
</SlideOverInfoRow>
<SlideOverServiceField
draft={draft}
pmTypes={pmTypes}
services={services}
serviceQuery={serviceQuery}
onDraftChange={onDraftChange}
/>
<SlideOverInfoRow label="Vendor">
<VendorCell
vendorId={draft.vendorId}

View file

@ -38,52 +38,24 @@ type WorkOrderSlideOverTabsProps = {
onRequestComplete?: () => void;
};
export function WorkOrderSlideOverTabs({
type SecondarySlideOverTabProps = Pick<
WorkOrderSlideOverTabsProps,
"tab" | "state" | "users" | "commentHighlight" | "onOpenCompletionDoc"
>;
function SecondarySlideOverTabs({
tab,
state,
saving,
users,
sites,
vendors,
pmTypes,
services = [],
serviceHistory,
serviceQuery,
serviceHistoryReady = true,
allOrders,
commentHighlight,
onDuplicateFound,
onOpenCompletionDoc,
onRequestComplete,
}: WorkOrderSlideOverTabsProps) {
}: SecondarySlideOverTabProps) {
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}
isCompleted={state.isCompleted}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryReady={serviceHistoryReady}
allOrders={allOrders}
onDraftChange={state.handleDraftChange}
onDuplicateFound={onDuplicateFound}
onRequestComplete={onRequestComplete}
/>
)}
<>
{tab === "comments" && (
<WorkOrderSlideOverTabPanel
variant="comments"
@ -125,7 +97,7 @@ export function WorkOrderSlideOverTabs({
</WorkOrderSlideOverTabPanel>
)}
{tab === "completion" && state.infoSource != null && (
{tab === "completion" && infoSource != null && (
<WorkOrderSlideOverTabPanel
variant="completion"
showSkeleton={state.showDetailSkeleton}
@ -135,16 +107,14 @@ export function WorkOrderSlideOverTabs({
showContent={showContent}
>
<SlideOverCompletionTab
row={state.infoSource}
row={infoSource}
isReadOnly={state.isDocumentsReadOnly}
completion={state.detailForRow?.completion}
media={state.completionMedia}
mediaCounts={state.mediaCounts}
mediaPending={state.mediaPending}
completionPending={state.completionPending}
onUploadMedia={(requests) => {
state.uploadMediaRequests(requests);
}}
onUploadMedia={(requests) => state.uploadMediaRequests(requests)}
onCategorizeMedia={state.categorizeMedia}
onRemoveMedia={state.removeMedia}
onRetryMedia={state.retryUpload}
@ -175,14 +145,67 @@ export function WorkOrderSlideOverTabs({
)}
isReadOnly={state.isExtrasReadOnly}
pending={state.mediaPending}
onUpload={(files) => {
void state.uploadExtraFiles(files);
}}
onUpload={(files) => void state.uploadExtraFiles(files)}
onRemove={state.removeMedia}
onCategorize={state.categorizeMedia}
/>
</WorkOrderSlideOverTabPanel>
)}
</>
);
}
export function WorkOrderSlideOverTabs({
tab,
state,
saving,
users,
sites,
vendors,
pmTypes,
services = [],
serviceHistory,
serviceQuery,
serviceHistoryReady = true,
allOrders,
commentHighlight,
onDuplicateFound,
onOpenCompletionDoc,
onRequestComplete,
}: WorkOrderSlideOverTabsProps) {
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}
isCompleted={state.isCompleted}
users={users}
sites={sites}
vendors={vendors}
pmTypes={pmTypes}
services={services}
serviceHistory={serviceHistory}
serviceQuery={serviceQuery}
serviceHistoryReady={serviceHistoryReady}
allOrders={allOrders}
onDraftChange={state.handleDraftChange}
onDuplicateFound={onDuplicateFound}
onRequestComplete={onRequestComplete}
/>
)}
<SecondarySlideOverTabs
tab={tab}
state={state}
users={users}
commentHighlight={commentHighlight}
onOpenCompletionDoc={onOpenCompletionDoc}
/>
</Box>
);
}

View file

@ -34,6 +34,205 @@ import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { Text } from "@/components/ui/text";
import { WIZARD_STATUS_DOTS } from "@/domain/work-orders/types/work-order-wizard";
type FilterActions = Pick<AdvancedSearchFilterSectionsProps, "draft" | "toggleIn" | "setMany">;
function AdvSelectAllRow({
setMany,
keyName,
values,
selectedCount,
}: {
setMany: AdvancedSearchFilterSectionsProps["setMany"];
keyName: AdvancedFilterSetKey;
values: string[];
selectedCount: number;
}) {
return (
<div className="mb-2 flex justify-end">
<SelectAllAction
anySelected={selectedCount > 0}
onClick={() => setMany(keyName, values, selectedCount === 0)}
/>
</div>
);
}
function AdvancedStatusSection({
draft,
setDraft,
toggleIn,
setMany,
}: FilterActions & Pick<AdvancedSearchFilterSectionsProps, "setDraft">) {
return (
<AdvSection
icon={CheckCircle2}
label="Status"
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
>
<AdvSelectAllRow
setMany={setMany}
keyName="statuses"
values={[...ALL_WIZARD_STATUSES]}
selectedCount={draft.statuses.size}
/>
<div className="flex flex-wrap gap-1.5">
{ALL_WIZARD_STATUSES.map((status) => (
<AdvStatusPill
key={status}
label={status}
dotColor={WIZARD_STATUS_DOTS[status]}
selected={draft.statuses.has(status)}
selectedBg="var(--color-primary-tint)"
onClick={() => toggleIn("statuses", status)}
/>
))}
<AdvStatusPill
label="Past Due"
dotColor="var(--status-pastdue-text)"
selected={draft.pastDue}
selectedBg="var(--status-pastdue-bg)"
selectedText="var(--status-pastdue-text)"
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
/>
</div>
</AdvSection>
);
}
type AdvancedServiceSectionProps = FilterActions &
Pick<AdvancedSearchFilterSectionsProps, "services" | "serviceQuery" | "pmTypes">;
function buildServiceFilterOptions(
draft: WorkOrderAdvancedFilters,
services: Service[] | undefined,
pmTypes: string[] | undefined,
) {
const serviceRegistryManaged = services !== undefined;
const serviceList = services ?? [];
const options = serviceRegistryManaged
? [
...serviceList.map((service) => ({ value: service.name, label: service.name })),
...[...draft.pmTypes]
.filter((name) => !serviceList.some((service) => service.name === name))
.map((name) => ({ value: name, label: name })),
]
: (pmTypes ?? []).map((name) => ({ value: name, label: name }));
return { serviceRegistryManaged, serviceList, options };
}
function AdvancedServiceRegistryFeedback({
query,
managed,
services,
}: {
query?: ServiceRegistryQueryState;
managed: boolean;
services: Service[];
}) {
const showEmpty =
managed &&
query?.hasData === true &&
!query.isLoading &&
!query.isError &&
services.length === 0;
return (
<>
{Boolean(query?.isLoading && !query.hasData) && (
<div role="status" aria-label="Loading services">
<Text as="span" variant="description">
Loading services…
</Text>
</div>
)}
{Boolean(query?.isFetching && query.hasData && !query.isError) && (
<div role="status" aria-label="Updating services">
<Text as="span" variant="description">
Updating services…
</Text>
</div>
)}
{Boolean(query?.isError) && (
<div role="alert" aria-label="Service filters unavailable">
<QueryErrorPanel
compact
message="Service filters couldn't load. Retry to load registry services."
onRetry={query?.retry}
/>
</div>
)}
{showEmpty && (
<Text as="div" variant="description">
No services are available.
</Text>
)}
</>
);
}
function AdvancedServiceSection({
draft,
toggleIn,
setMany,
services,
serviceQuery,
pmTypes,
}: AdvancedServiceSectionProps) {
const { serviceRegistryManaged, serviceList, options } = buildServiceFilterOptions(
draft,
services,
pmTypes,
);
const unavailable =
serviceRegistryManaged &&
(serviceQuery?.isLoading === true ||
(serviceQuery?.isError === true && serviceQuery.hasData === false));
return (
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
<AdvancedServiceRegistryFeedback
query={serviceQuery}
managed={serviceRegistryManaged}
services={serviceList}
/>
<AdvMultiSelect
options={options}
selected={draft.pmTypes}
onToggle={(value) => toggleIn("pmTypes", value)}
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
placeholder="Search service…"
disabled={unavailable}
renderLeft={(name) => (
<PmTypeIcon pmType={name} size={14} className="shrink-0 text-muted-foreground" />
)}
/>
</AdvSection>
);
}
function AdvancedCompletionDocSection({ draft, toggleIn, setMany }: FilterActions) {
return (
<AdvSection icon={FileText} label="Completion doc" count={draft.docs.size}>
<AdvSelectAllRow
setMany={setMany}
keyName="docs"
values={[...COMPLETION_DOC_OPTIONS]}
selectedCount={draft.docs.size}
/>
<div className="flex items-center gap-2">
{COMPLETION_DOC_OPTIONS.map((doc) => (
<AdvDocPill
key={doc}
label={DOC_FILTER_LABELS[doc]}
selected={draft.docs.has(doc)}
onClick={() => toggleIn("docs", doc)}
/>
))}
</div>
</AdvSection>
);
}
type AdvancedSearchFilterSectionsProps = {
draft: WorkOrderAdvancedFilters;
setDraft: Dispatch<SetStateAction<WorkOrderAdvancedFilters>>;
@ -59,29 +258,6 @@ export function AdvancedSearchFilterSections({
pmTypes = [],
vendorTechs,
}: AdvancedSearchFilterSectionsProps) {
const selectAllRow = (key: AdvancedFilterSetKey, all: string[], selectedSize: number) => (
<div className="mb-2 flex justify-end">
<SelectAllAction
anySelected={selectedSize > 0}
onClick={() => setMany(key, all, selectedSize === 0)}
/>
</div>
);
const serviceRegistryManaged = services !== undefined;
const serviceList = services ?? [];
const serviceOptions = serviceRegistryManaged
? [
...serviceList.map((service) => ({ value: service.name, label: service.name })),
...[...draft.pmTypes]
.filter((name) => !serviceList.some((service) => service.name === name))
.map((name) => ({ value: name, label: name })),
]
: pmTypes.map((name) => ({ value: name, label: name }));
const serviceChoicesUnavailable =
serviceRegistryManaged &&
(serviceQuery?.isLoading === true ||
(serviceQuery?.isError === true && serviceQuery.hasData === false));
return (
<div className="min-h-0 flex-1 overflow-y-auto px-4">
<AdvDateRangeSection draft={draft} setDraft={setDraft} />
@ -131,83 +307,23 @@ export function AdvancedSearchFilterSections({
/>
</AdvSection>
<AdvSection
icon={CheckCircle2}
label="Status"
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
>
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
<div className="flex flex-wrap gap-1.5">
{ALL_WIZARD_STATUSES.map((status) => (
<AdvStatusPill
key={status}
label={status}
dotColor={WIZARD_STATUS_DOTS[status]}
selected={draft.statuses.has(status)}
selectedBg="var(--color-primary-tint)"
onClick={() => toggleIn("statuses", status)}
/>
))}
<AdvStatusPill
label="Past Due"
dotColor="var(--status-pastdue-text)"
selected={draft.pastDue}
selectedBg="var(--status-pastdue-bg)"
selectedText="var(--status-pastdue-text)"
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
/>
</div>
</AdvSection>
<AdvancedStatusSection
draft={draft}
setDraft={setDraft}
toggleIn={toggleIn}
setMany={setMany}
/>
<AdvIndicatorsSection draft={draft} setDraft={setDraft} />
<AdvSection icon={Flame} label="Service" count={draft.pmTypes.size}>
{Boolean(serviceQuery?.isLoading && !serviceQuery.hasData) && (
<div role="status" aria-label="Loading services">
<Text as="span" variant="description">
Loading services…
</Text>
</div>
)}
{Boolean(serviceQuery?.isFetching && serviceQuery.hasData && !serviceQuery.isError) && (
<div role="status" aria-label="Updating services">
<Text as="span" variant="description">
Updating services…
</Text>
</div>
)}
{Boolean(serviceQuery?.isError) && (
<div role="alert" aria-label="Service filters unavailable">
<QueryErrorPanel
compact
message="Service filters couldn't load. Retry to load registry services."
onRetry={serviceQuery?.retry}
/>
</div>
)}
{Boolean(
serviceRegistryManaged &&
serviceQuery?.hasData &&
!serviceQuery.isLoading &&
!serviceQuery.isError &&
serviceList.length === 0,
) && (
<Text as="div" variant="description">
No services are available.
</Text>
)}
<AdvMultiSelect
options={serviceOptions}
selected={draft.pmTypes}
onToggle={(value) => toggleIn("pmTypes", value)}
onBulkToggle={(values, select) => setMany("pmTypes", values, select)}
placeholder="Search service…"
disabled={serviceChoicesUnavailable}
renderLeft={(name) => (
<PmTypeIcon pmType={name} size={14} className="shrink-0 text-muted-foreground" />
)}
/>
</AdvSection>
<AdvancedServiceSection
draft={draft}
toggleIn={toggleIn}
setMany={setMany}
services={services}
serviceQuery={serviceQuery}
pmTypes={pmTypes}
/>
<AdvSection icon={Truck} label="Vendor / tech" count={draft.vendorTechs.size}>
<AdvMultiSelect
@ -219,19 +335,7 @@ export function AdvancedSearchFilterSections({
/>
</AdvSection>
<AdvSection icon={FileText} label="Completion doc" count={draft.docs.size}>
{selectAllRow("docs", [...COMPLETION_DOC_OPTIONS], draft.docs.size)}
<div className="flex items-center gap-2">
{COMPLETION_DOC_OPTIONS.map((doc) => (
<AdvDocPill
key={doc}
label={DOC_FILTER_LABELS[doc]}
selected={draft.docs.has(doc)}
onClick={() => toggleIn("docs", doc)}
/>
))}
</div>
</AdvSection>
<AdvancedCompletionDocSection draft={draft} toggleIn={toggleIn} setMany={setMany} />
<AdvFlagInternalUpliftSections
draft={draft}

View file

@ -4,22 +4,41 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import { resolveCreationStatus } from "@/domain/work-orders/utils/wo-lifecycle-transitions";
function resolveInlineDraftLocation(draft: WorkOrderTableDraft, sites: LocationOption[]) {
const location = sites.find((site) => String(site.id) === String(draft.locationId));
const locationName = draft.location || draft.site || location?.name || "";
return {
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
};
}
function resolveInlineDraftSchedule(draft: WorkOrderTableDraft) {
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
return {
scheduleWeekOnly,
status: resolveCreationStatus({
status: draft.status || "Incomplete",
scheduledOn: draft.scheduledOn,
scheduleWeekOnly,
}),
};
}
export function buildInlineDraftCreateInput(
draft: WorkOrderTableDraft,
users: WorkOrderUser[],
sites: LocationOption[],
): WorkOrderWizardDraft {
const assignee = users.find((u) => String(u.id) === draft.dispatcherId);
const location = sites.find((s) => String(s.id) === String(draft.locationId));
const locationName = draft.location || draft.site || location?.name || "";
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
const location = resolveInlineDraftLocation(draft, sites);
const schedule = resolveInlineDraftSchedule(draft);
return {
locationId: String(draft.locationId || ""),
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
locationName: location.locationName,
siteCode: location.siteCode,
woNumber: draft.woNumber,
type: draft.type || "PM",
severity: draft.severity,
@ -39,16 +58,12 @@ export function buildInlineDraftCreateInput(
vendorNotes: "",
apptStart: draft.apptStart,
apptEnd: draft.apptEnd,
status: resolveCreationStatus({
status: draft.status || "Incomplete",
scheduledOn: draft.scheduledOn,
scheduleWeekOnly,
}),
status: schedule.status,
pocName: draft.pocName,
pocPhone: draft.pocPhone,
pocNotes: draft.pocNotes,
additionalContacts: draft.additionalContacts ?? [],
scheduleWeekOnly,
scheduleWeekOnly: schedule.scheduleWeekOnly,
targetWeek: draft.targetWeek || "",
scheduleEnd: draft.scheduleEnd || "",
schedMode: draft.schedMode,

View file

@ -36,183 +36,277 @@ type PMTypeCellProps = {
serviceQuery?: ServiceRegistryQueryState;
};
export function PMTypeCell({
value,
pmTypes,
q,
onSave,
serviceNotes = "",
extraServices = [],
avetaRequired = false,
avetaAttached = false,
woNumber,
onSaveDetails,
viewOnly = false,
services,
serviceId,
workOrderType = "",
serviceQuery,
}: PMTypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState("");
const [modalOpen, setModalOpen] = useState(false);
type PMTypeCellModel = {
assignableServices?: Service[];
displayValue: string;
filtered: string[];
registryManaged: boolean;
choicesUnavailable: boolean;
catalogEmpty: boolean;
hasDetails: boolean;
notePreview: string;
};
const assignableServices =
services == null ? undefined : servicesForWorkOrderType(services, workOrderType);
const serviceNames = assignableServices?.map((service) => service.name) ?? pmTypes;
const filtered = search
? serviceNames.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
: serviceNames;
const linkedService = services?.find((service) => String(service.id) === String(serviceId));
const displayValue = !viewOnly && linkedService?.isActive ? linkedService.name : value;
const serviceRegistryManaged = services !== undefined;
const serviceChoicesUnavailable =
serviceRegistryManaged &&
(serviceQuery?.isLoading === true ||
(serviceQuery?.isError === true && serviceQuery.hasData === false));
const serviceCatalogEmpty =
serviceRegistryManaged &&
(serviceQuery?.hasData ?? true) &&
!serviceQuery?.isLoading &&
!serviceQuery?.isError &&
serviceNames.length === 0;
function getAssignableServices(props: PMTypeCellProps) {
return props.services == null
? undefined
: servicesForWorkOrderType(props.services, props.workOrderType ?? "");
}
const extras = extraServices ?? [];
const hasDetails = hasServiceNotesDetails(serviceNotes, extras, avetaRequired);
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
function getCellDisplayValue(props: PMTypeCellProps) {
const linkedService = props.services?.find(
(service) => String(service.id) === String(props.serviceId),
);
return !props.viewOnly && linkedService?.isActive ? linkedService.name : props.value;
}
const closePopover = () => {
setAnchorEl(null);
setSearch("");
function getServiceChoicesUnavailable(props: PMTypeCellProps, registryManaged: boolean) {
const query = props.serviceQuery;
return (
registryManaged &&
(query?.isLoading === true || (query?.isError === true && query.hasData === false))
);
}
function isServiceCatalogEmpty(
props: PMTypeCellProps,
registryManaged: boolean,
services: Service[] | undefined,
) {
const query = props.serviceQuery;
return (
registryManaged &&
(query?.hasData ?? true) &&
!query?.isLoading &&
!query?.isError &&
(services?.length ?? props.pmTypes.length) === 0
);
}
function resolvePMTypeCellModel(props: PMTypeCellProps, search: string): PMTypeCellModel {
const assignableServices = getAssignableServices(props);
const serviceNames = assignableServices?.map((service) => service.name) ?? props.pmTypes;
const registryManaged = props.services !== undefined;
return {
assignableServices,
displayValue: getCellDisplayValue(props),
filtered: search
? serviceNames.filter((name) => name.toLowerCase().includes(search.toLowerCase()))
: serviceNames,
registryManaged,
choicesUnavailable: getServiceChoicesUnavailable(props, registryManaged),
catalogEmpty: isServiceCatalogEmpty(props, registryManaged, assignableServices),
hasDetails: hasServiceNotesDetails(
props.serviceNotes ?? "",
props.extraServices ?? [],
props.avetaRequired ?? false,
),
notePreview: props.serviceNotes?.trim() ? truncateServiceNotesPreview(props.serviceNotes) : "",
};
}
const modal =
onSaveDetails != null || viewOnly ? (
<ServiceNotesDialog
open={modalOpen}
onOpenChange={setModalOpen}
pm={displayValue}
extraServices={extras}
serviceNotes={serviceNotes ?? ""}
avetaRequired={avetaRequired}
woNumber={woNumber}
pmTypes={pmTypes}
services={assignableServices}
serviceId={serviceId}
viewOnly={viewOnly}
onSave={onSaveDetails ?? (() => undefined)}
function PMTypeCellDialog({
props,
model,
open,
onOpenChange,
}: {
props: PMTypeCellProps;
model: PMTypeCellModel;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
if (!props.viewOnly && props.onSaveDetails == null) return null;
return (
<ServiceNotesDialog
open={open}
onOpenChange={onOpenChange}
pm={model.displayValue}
extraServices={props.extraServices ?? []}
serviceNotes={props.serviceNotes ?? ""}
avetaRequired={props.avetaRequired ?? false}
woNumber={props.woNumber}
pmTypes={props.pmTypes}
services={model.assignableServices}
serviceId={props.serviceId}
viewOnly={props.viewOnly ?? false}
onSave={props.onSaveDetails ?? (() => undefined)}
/>
);
}
function PMTypeCellReadOnly({
props,
model,
onOpenDetails,
}: {
props: PMTypeCellProps;
model: PMTypeCellModel;
onOpenDetails: () => void;
}) {
return (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onOpenDetails();
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<PmTypeIcon
pmType={model.displayValue}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
) : null;
if (viewOnly) {
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setModalOpen(true);
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
{model.displayValue ? (
<span
className="min-w-0 truncate"
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
>
<PmTypeIcon
pmType={displayValue}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{displayValue ? (
<span
className="min-w-0 truncate"
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
>
<HighlightText text={displayValue} q={q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
)}
{Boolean(avetaRequired) && <AvetaBadge size={11} attached={avetaAttached} />}
</button>
{modal}
</>
);
}
<HighlightText text={model.displayValue} q={props.q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>—</span>
)}
{Boolean(props.avetaRequired) && <AvetaBadge size={11} attached={props.avetaAttached} />}
</button>
);
}
if (hasDetails && onSaveDetails) {
return (
<>
<PmTypeDetailsTrigger
value={displayValue}
q={q}
notePreview={notePreview}
extrasCount={extras.length}
avetaRequired={avetaRequired}
avetaAttached={avetaAttached}
onOpenDetails={() => setModalOpen(true)}
/>
{modal}
</>
);
}
function PMTypeCellDetails({
props,
model,
onOpenDetails,
}: {
props: PMTypeCellProps;
model: PMTypeCellModel;
onOpenDetails: () => void;
}) {
return (
<PmTypeDetailsTrigger
value={model.displayValue}
q={props.q}
notePreview={model.notePreview}
extrasCount={(props.extraServices ?? []).length}
avetaRequired={props.avetaRequired ?? false}
avetaAttached={props.avetaAttached ?? false}
onOpenDetails={onOpenDetails}
/>
);
}
function PMTypeCellPicker({
props,
model,
anchorEl,
search,
onOpenPicker,
onSearchChange,
onClose,
onOpenDetails,
}: {
props: PMTypeCellProps;
model: PMTypeCellModel;
anchorEl: HTMLElement | null;
search: string;
onOpenPicker: (anchor: HTMLElement) => void;
onSearchChange: (search: string) => void;
onClose: () => void;
onOpenDetails: () => void;
}) {
const onPick = (name: string) => {
const service = model.assignableServices?.find((candidate) => candidate.name === name);
props.onSave(name, service?.id);
onClose();
};
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
onClick={(event) => {
event.stopPropagation();
onOpenPicker(event.currentTarget);
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<PmTypeIcon
pmType={displayValue}
pmType={model.displayValue}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{displayValue ? (
{model.displayValue ? (
<span
className="min-w-0 truncate"
style={{
fontSize: 13,
color: "var(--color-text-primary)",
}}
style={{ fontSize: 13, color: "var(--color-text-primary)" }}
>
<HighlightText text={displayValue} q={q} />
<HighlightText text={model.displayValue} q={props.q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
)}
<HoverChevron />
</button>
<PmTypePickerPopover
anchorEl={anchorEl}
search={search}
filtered={filtered}
value={displayValue}
showAddDetails={Boolean(onSaveDetails)}
onSearchChange={setSearch}
onClose={closePopover}
registryManaged={serviceRegistryManaged}
loading={serviceQuery?.isLoading === true && !serviceQuery.hasData}
fetching={serviceQuery?.isFetching === true && serviceQuery.hasData}
error={serviceQuery?.isError === true}
empty={serviceCatalogEmpty}
selectionDisabled={serviceChoicesUnavailable}
onRetry={serviceQuery?.retry}
onPick={(pm) => {
const picked = assignableServices?.find((service) => service.name === pm);
onSave(pm, picked?.id);
closePopover();
}}
filtered={model.filtered}
value={model.displayValue}
showAddDetails={Boolean(props.onSaveDetails)}
onSearchChange={onSearchChange}
onClose={onClose}
registryManaged={model.registryManaged}
loading={props.serviceQuery?.isLoading === true && !props.serviceQuery.hasData}
fetching={props.serviceQuery?.isFetching === true && props.serviceQuery.hasData}
error={props.serviceQuery?.isError === true}
empty={model.catalogEmpty}
selectionDisabled={model.choicesUnavailable}
onRetry={props.serviceQuery?.retry}
onPick={onPick}
onAddDetails={() => {
closePopover();
setModalOpen(true);
onClose();
onOpenDetails();
}}
/>
{modal}
</>
);
}
export function PMTypeCell(props: PMTypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const model = resolvePMTypeCellModel(props, search);
const closePicker = () => {
setAnchorEl(null);
setSearch("");
};
const openDetails = () => setModalOpen(true);
const content = props.viewOnly ? (
<PMTypeCellReadOnly props={props} model={model} onOpenDetails={openDetails} />
) : model.hasDetails && props.onSaveDetails ? (
<PMTypeCellDetails props={props} model={model} onOpenDetails={openDetails} />
) : (
<PMTypeCellPicker
props={props}
model={model}
anchorEl={anchorEl}
search={search}
onOpenPicker={setAnchorEl}
onSearchChange={setSearch}
onClose={closePicker}
onOpenDetails={openDetails}
/>
);
return (
<>
{content}
<PMTypeCellDialog props={props} model={model} open={modalOpen} onOpenChange={setModalOpen} />
</>
);
}

View file

@ -18,14 +18,23 @@ type PersistPatchFn = (
options?: { onSuccess?: () => void },
) => void;
export function saveExistingRowType(
onPatch: PersistPatchFn,
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity" | "serviceId">,
type: WOType,
severity: WorkOrderSeverity | null,
serviceHistory: Service[] = [],
type SaveExistingRowTypeOptions = {
onPatch: PersistPatchFn;
row: Pick<WorkOrderTableRow, "id" | "woNumber" | "severity" | "serviceId">;
type: WOType;
severity: WorkOrderSeverity | null;
serviceHistory?: Service[];
serviceHistoryReady?: boolean;
};
export function saveExistingRowType({
onPatch,
row,
type,
severity,
serviceHistory = [],
serviceHistoryReady = true,
) {
}: SaveExistingRowTypeOptions) {
const selectedService = serviceHistoryReady
? serviceHistory.find((service) => String(service.id) === String(row.serviceId))
: undefined;

View file

@ -127,14 +127,14 @@ export function WoTableRowCells({
q={search}
viewOnly={isWorkOrderCompletionSurfaceReadOnly(row.status)}
onSave={(t, sev) =>
saveExistingRowType(
handlers.onPatch,
saveExistingRowType({
onPatch: handlers.onPatch,
row,
t,
sev,
serviceHistory ?? services,
type: t,
severity: sev,
serviceHistory: serviceHistory ?? services,
serviceHistoryReady,
)
})
}
/>
</td>

View file

@ -93,6 +93,112 @@ function WoTablePager({ tableData }: { tableData: ReturnTypeOfUseWorkOrdersTable
);
}
type WoTableRowRenderProps = {
row: WorkOrderTableRow;
allRows: WorkOrderTableRow[];
users: WorkOrderUser[];
sites: LocationOption[];
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
services: Service[];
serviceHistory: Service[];
serviceQuery?: ServiceRegistryQueryState;
serviceHistoryReady: boolean;
pmTypes: string[];
search: string;
forceScheduleHighlight: boolean;
forceDueDateHighlight: boolean;
isSelected: boolean;
isFlashing: boolean;
dragViz: ReturnTypeOfUseWorkOrdersTableData["dragViz"];
handlers: ReturnType<typeof useWoTableRowHandlers>;
};
function WoTableRenderedRow(props: WoTableRowRenderProps) {
return <WoTableRow {...props} />;
}
type WoTableInlineRenderProps = {
tableData: ReturnTypeOfUseWorkOrdersTableData;
dayKey: string;
users: WorkOrderUser[];
sites: LocationOption[];
sitePlace?: { city: string; state: string };
vendors: VendorDropdownItem[];
services: Service[];
serviceQuery?: ServiceRegistryQueryState;
pmTypes: string[];
saving: boolean;
handleInlineSave: () => void;
onDuplicateFound: (row: WorkOrderTableRow) => void;
};
function WoTableRenderedInlineRow({
tableData,
dayKey,
users,
sites,
sitePlace,
vendors,
services,
serviceQuery,
pmTypes,
saving,
handleInlineSave,
onDuplicateFound,
}: WoTableInlineRenderProps) {
if (tableData.draftDayKey !== dayKey) return null;
return (
<WoTableInlineRow
draft={tableData.draft}
draftDayKey={tableData.draftDayKey}
users={users}
sites={sites}
sitePlace={sitePlace}
vendors={vendors}
services={services}
serviceQuery={serviceQuery}
pmTypes={pmTypes}
allRows={tableData.rows}
saving={saving}
onChange={tableData.setDraft}
onSave={handleInlineSave}
onCancel={tableData.cancelDraft}
onDuplicateFound={onDuplicateFound}
/>
);
}
function WoTableEmptyResult({
tableData,
onOpenAdvancedFilters,
onClearSearch,
}: {
tableData: ReturnTypeOfUseWorkOrdersTableData;
onOpenAdvancedFilters?: () => void;
onClearSearch?: () => void;
}) {
if (tableData.advActive && tableData.scopeTotalCount === 0) {
return (
<WoTableEmptyShell
message="No work orders match these filters"
actionLabel="Adjust filters"
onAction={onOpenAdvancedFilters}
/>
);
}
if (!tableData.advActive && tableData.noSearchResults) {
return (
<WoTableEmptyShell
message="No work orders match your search"
actionLabel="Clear search"
onAction={onClearSearch}
/>
);
}
return null;
}
export function WoTable({
tableData,
users,
@ -141,7 +247,7 @@ export function WoTable({
});
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
<WoTableRow
<WoTableRenderedRow
key={String(row.id)}
row={row}
allRows={tableData.rows}
@ -166,30 +272,26 @@ export function WoTable({
/>
);
const renderInlineRow = (dayKey: string) =>
tableData.draftDayKey === dayKey && (
<WoTableInlineRow
draft={tableData.draft}
draftDayKey={tableData.draftDayKey}
users={users}
sites={sites}
sitePlace={resolveSitePlace(
tableData.draft.locationId,
tableData.draft.site,
sitePlaceLookups,
)}
vendors={vendors}
services={services}
serviceQuery={serviceQuery}
pmTypes={pmTypes}
allRows={tableData.rows}
saving={saving}
onChange={tableData.setDraft}
onSave={handleInlineSave}
onCancel={tableData.cancelDraft}
onDuplicateFound={setDuplicateRow}
/>
);
const renderInlineRow = (dayKey: string) => (
<WoTableRenderedInlineRow
tableData={tableData}
dayKey={dayKey}
users={users}
sites={sites}
sitePlace={resolveSitePlace(
tableData.draft.locationId,
tableData.draft.site,
sitePlaceLookups,
)}
vendors={vendors}
services={services}
serviceQuery={serviceQuery}
pmTypes={pmTypes}
saving={saving}
handleInlineSave={handleInlineSave}
onDuplicateFound={setDuplicateRow}
/>
);
if (tableData.isLoading) {
return (
@ -197,29 +299,14 @@ export function WoTable({
);
}
const emptyAdv = tableData.advActive && tableData.scopeTotalCount === 0;
if (emptyAdv) {
return (
<WoTableEmptyShell
message="No work orders match these filters"
actionLabel="Adjust filters"
onAction={onOpenAdvancedFilters}
/>
);
}
const showSearchEmpty = !tableData.advActive && tableData.noSearchResults;
if (showSearchEmpty) {
return (
<WoTableEmptyShell
message="No work orders match your search"
actionLabel="Clear search"
onAction={onClearSearch}
/>
);
}
const emptyResult = (
<WoTableEmptyResult
tableData={tableData}
onOpenAdvancedFilters={onOpenAdvancedFilters}
onClearSearch={onClearSearch}
/>
);
if (emptyResult != null) return emptyResult;
return (
<>

View file

@ -0,0 +1,39 @@
import type { UseQueryResult } from "@tanstack/react-query";
import type { Service } from "@/domain/services/types/service";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import {
useActiveServices,
useAllServices,
} from "@/domain/services/use-cases/use-services-for-work-order-type";
function toServiceRegistryQueryState(
query: UseQueryResult<Service[], Error>,
): ServiceRegistryQueryState {
return {
isLoading: query.isPending && query.data === undefined,
isError: query.isError,
isFetching: query.isFetching,
hasData: query.data !== undefined,
retry: () => void query.refetch(),
};
}
export function useWorkOrderServiceRegistry() {
const activeQuery = useActiveServices();
const historyQuery = useAllServices();
const servicePickerState = toServiceRegistryQueryState(activeQuery);
const serviceHistoryState = toServiceRegistryQueryState(historyQuery);
const serviceHistoryReadyForTypeChange =
serviceHistoryState.hasData &&
!serviceHistoryState.isLoading &&
!serviceHistoryState.isError &&
!serviceHistoryState.isFetching;
return {
services: activeQuery.data ?? [],
serviceHistory: historyQuery.data ?? [],
servicePickerState,
serviceHistoryState,
serviceHistoryReadyForTypeChange,
};
}

View file

@ -9,11 +9,6 @@ import {
} 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 {
useActiveServices,
useAllServices,
} from "@/domain/services/use-cases/use-services-for-work-order-type";
import type { ServiceRegistryQueryState } from "@/domain/services/types/service-registry-query-state";
import { shouldCompleteAfterCompletionDoc } from "@/domain/work-orders/utils/completion-doc-auto-complete";
import type { CommentHighlight } from "@/domain/work-orders/utils/work-order-deep-link";
import {
@ -23,6 +18,7 @@ import {
import { guardDialogClose } from "@/lib/dialog-guard";
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
import { useWorkOrderServiceRegistry } from "./use-work-order-service-registry";
function useSlideOverOpenState() {
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
@ -96,29 +92,7 @@ export function useWorkOrdersListPage() {
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const activeServicesQuery = useActiveServices();
const serviceHistoryQuery = useAllServices();
const services = activeServicesQuery.data ?? [];
const serviceHistory = serviceHistoryQuery.data ?? [];
const servicePickerState: ServiceRegistryQueryState = {
isLoading: activeServicesQuery.isPending && activeServicesQuery.data === undefined,
isError: activeServicesQuery.isError,
isFetching: activeServicesQuery.isFetching,
hasData: activeServicesQuery.data !== undefined,
retry: () => void activeServicesQuery.refetch(),
};
const serviceHistoryState: ServiceRegistryQueryState = {
isLoading: serviceHistoryQuery.isPending && serviceHistoryQuery.data === undefined,
isError: serviceHistoryQuery.isError,
isFetching: serviceHistoryQuery.isFetching,
hasData: serviceHistoryQuery.data !== undefined,
retry: () => void serviceHistoryQuery.refetch(),
};
const serviceHistoryReadyForTypeChange =
serviceHistoryState.hasData &&
!serviceHistoryState.isLoading &&
!serviceHistoryState.isError &&
!serviceHistoryState.isFetching;
const serviceRegistry = useWorkOrderServiceRegistry();
const pmTypes = useMemo(
() => problemOptions.map((option) => option.value).filter(Boolean),
@ -223,11 +197,7 @@ export function useWorkOrdersListPage() {
locations,
vendors,
pmTypes,
services,
serviceHistory,
servicePickerState,
serviceHistoryState,
serviceHistoryReadyForTypeChange,
...serviceRegistry,
vendorTechs,
activeSlideOverRow,
activeDocRow,

View file

@ -19,7 +19,7 @@ describe("saveExistingRowType", () => {
it("warns when severity newly enters SEV 1 only after persist onSuccess", () => {
const onPatch = vi.fn();
saveExistingRowType(onPatch, row, "Emergency", 1);
saveExistingRowType({ onPatch, row, type: "Emergency", severity: 1 });
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(
@ -36,7 +36,7 @@ describe("saveExistingRowType", () => {
it("does not warn when staying on a critical severity", () => {
const onPatch = vi.fn();
saveExistingRowType(onPatch, { ...row, severity: 1 }, "Emergency", 1);
saveExistingRowType({ onPatch, row: { ...row, severity: 1 }, type: "Emergency", severity: 1 });
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: 1 });
@ -46,7 +46,7 @@ describe("saveExistingRowType", () => {
it("does not warn when moving between SEV 3–5", () => {
const onPatch = vi.fn();
saveExistingRowType(onPatch, { ...row, severity: 5 }, "Reactive", 3);
saveExistingRowType({ onPatch, row: { ...row, severity: 5 }, type: "Reactive", severity: 3 });
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: 3 });
@ -56,7 +56,7 @@ describe("saveExistingRowType", () => {
it("does not warn when clearing severity on a PM correction", () => {
const onPatch = vi.fn();
saveExistingRowType(onPatch, { ...row, severity: 2 }, "PM", null);
saveExistingRowType({ onPatch, row: { ...row, severity: 2 }, type: "PM", severity: null });
expect(toastWarn).not.toHaveBeenCalled();
expect(onPatch).toHaveBeenCalledWith(7, { type: "PM", severity: null });
@ -78,7 +78,13 @@ describe("saveExistingRowType", () => {
},
];
saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Emergency", null, services);
saveExistingRowType({
onPatch,
row: { ...row, serviceId: 701 },
type: "Emergency",
severity: null,
serviceHistory: services,
});
expect(onPatch).toHaveBeenCalledWith(7, {
type: "Emergency",
@ -103,7 +109,13 @@ describe("saveExistingRowType", () => {
},
];
saveExistingRowType(onPatch, { ...row, serviceId: 702 }, "Reactive", null, services);
saveExistingRowType({
onPatch,
row: { ...row, serviceId: 702 },
type: "Reactive",
severity: null,
serviceHistory: services,
});
expect(onPatch).toHaveBeenCalledWith(7, { type: "Reactive", severity: null });
});
@ -123,7 +135,13 @@ describe("saveExistingRowType", () => {
},
];
saveExistingRowType(onPatch, { ...row, serviceId: 701 }, "Emergency", null, services);
saveExistingRowType({
onPatch,
row: { ...row, serviceId: 701 },
type: "Emergency",
severity: null,
serviceHistory: services,
});
expect(onPatch).toHaveBeenCalledWith(7, {
type: "Emergency",
@ -146,14 +164,14 @@ describe("saveExistingRowType", () => {
supportedWorkOrderTypes: ["PM"],
};
saveExistingRowType(
saveExistingRowType({
onPatch,
{ ...row, serviceId: 701 },
"Emergency",
null,
[inactiveService],
false,
);
row: { ...row, serviceId: 701 },
type: "Emergency",
severity: null,
serviceHistory: [inactiveService],
serviceHistoryReady: false,
});
expect(onPatch).toHaveBeenCalledWith(7, { type: "Emergency", severity: null });
});