mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 04:33:23 +00:00
refactor(workorders): meet maintainability gate
This commit is contained in:
parent
ffbf60d16a
commit
d40d4eaffb
11 changed files with 821 additions and 439 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue