mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Compare commits
23 commits
1f405bbcaf
...
51739fd155
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51739fd155 | ||
|
|
2583ad63d1 | ||
|
|
dd34e79e19 | ||
|
|
5d1b6e5aa0 | ||
|
|
77b679b8f6 | ||
|
|
8b5b5903fa | ||
|
|
b746087d0b | ||
|
|
0f34e8c7c6 | ||
|
|
e2a81e3ff8 | ||
|
|
754912c643 | ||
|
|
49fd0b039c | ||
|
|
baf16501cc | ||
|
|
632d01f373 | ||
|
|
785e112f63 | ||
|
|
46f90abf8a | ||
|
|
71432ac586 | ||
|
|
ad1fd7ebf9 | ||
|
|
e65049691c | ||
|
|
c75f8b6239 | ||
|
|
8774b2e0a6 | ||
|
|
f6a22a9096 | ||
|
|
e46e42df69 | ||
|
|
bd69350a7c |
28 changed files with 652 additions and 104 deletions
|
|
@ -6,6 +6,7 @@ import {
|
|||
getDispatcherAvatarColor,
|
||||
getInitialsFromName,
|
||||
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||
import { formatAuditTimestamp } from "@/domain/work-orders/utils/format-audit-timestamp";
|
||||
import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
|
||||
|
||||
function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
||||
|
|
@ -15,36 +16,6 @@ function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
|||
return !name || name.toLowerCase() === "system";
|
||||
}
|
||||
|
||||
function formatClock(date: Date): string {
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual (image): `· 9:48 AM`
|
||||
* System (image): `05/13 · 8:00 AM`
|
||||
* Prefer API display strings that already include · / AM|PM.
|
||||
*/
|
||||
function formatAuditTime(value: string, isSystem: boolean): string {
|
||||
if (!value) return "";
|
||||
if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value;
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
const timePart = formatClock(date);
|
||||
if (isSystem) {
|
||||
const datePart = date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
return `${datePart} · ${timePart}`;
|
||||
}
|
||||
return `· ${timePart}`;
|
||||
}
|
||||
|
||||
type SlideOverAuditListProps = {
|
||||
entries: WorkOrderAuditEntry[];
|
||||
users?: WorkOrderUser[];
|
||||
|
|
@ -151,7 +122,7 @@ export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListPr
|
|||
color: "#9CA3AF",
|
||||
}}
|
||||
>
|
||||
{formatAuditTime(entry.createdAt, isSys)}
|
||||
{formatAuditTimestamp(entry.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-
|
|||
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { hasCompletionMediaGallery } from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
import {
|
||||
formatMediaSizeLimit,
|
||||
isWorkOrderFileTooLarge,
|
||||
|
|
@ -16,10 +16,6 @@ import { SlideOverCompletionMedia } from "./slide-over-completion-media";
|
|||
import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status";
|
||||
import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs";
|
||||
|
||||
function isMediaWO(type: WOType | ""): boolean {
|
||||
return type === "Emergency" || type === "Reactive";
|
||||
}
|
||||
|
||||
type SlideOverCompletionTabProps = {
|
||||
row: WorkOrderTableRow;
|
||||
isReadOnly: boolean;
|
||||
|
|
@ -56,7 +52,7 @@ export function SlideOverCompletionTab({
|
|||
const templateUrl = completion?.template?.templateUrl;
|
||||
const signOffAttachment = completion?.signOffAttachment;
|
||||
|
||||
if (isMediaWO(row.type)) {
|
||||
if (hasCompletionMediaGallery(row.type)) {
|
||||
return (
|
||||
<SlideOverCompletionMedia
|
||||
isReadOnly={isReadOnly}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
|||
other: Paperclip,
|
||||
};
|
||||
|
||||
const FILETYPE_LABEL: Record<FileCategory, string> = {
|
||||
image: "Photo",
|
||||
video: "Video",
|
||||
pdf: "PDF",
|
||||
doc: "Document",
|
||||
other: "File",
|
||||
};
|
||||
|
||||
const EXTRA_DOC_PURPOSES: Array<Extract<UploadableMediaCategory, "Extra" | "Aveta">> = [
|
||||
"Extra",
|
||||
"Aveta",
|
||||
|
|
@ -92,8 +100,13 @@ function ExtraDocRow({
|
|||
const purpose = file.purpose === "Aveta" || isAveta ? "Aveta" : "Extra";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`${FILETYPE_LABEL[file.fileType]}, ${purpose}: ${file.name}`}
|
||||
className="flex items-center gap-2 py-1"
|
||||
>
|
||||
<ItemIcon
|
||||
aria-hidden
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
style={{ color: isAveta ? "var(--warning)" : "var(--color-text-muted)" }}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
UploadWorkOrderMediaInput,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import {
|
||||
hasCompletionMediaGallery,
|
||||
isCompletionSurfaceMedia,
|
||||
isExtrasSurfaceMedia,
|
||||
type MediaSurfaceOwnership,
|
||||
|
|
@ -80,6 +81,7 @@ export function useSlideOverMediaState({
|
|||
categorizeMediaMutation,
|
||||
}: UseSlideOverMediaStateArgs) {
|
||||
const workOrderId = detailForRow?.info.id;
|
||||
const completionHasGallery = hasCompletionMediaGallery(detailForRow?.info.type);
|
||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||
const [ownership, setOwnershipState] = useState<MediaSurfaceOwnership>(() =>
|
||||
loadOwnership(workOrderId),
|
||||
|
|
@ -116,16 +118,16 @@ export function useSlideOverMediaState({
|
|||
useMediaUploadSession(detailForRow, persistCompletionUpload);
|
||||
|
||||
const completionMedia = useMemo(
|
||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership)),
|
||||
[uploads, ownership],
|
||||
() => uploads.filter((item) => isCompletionSurfaceMedia(item, ownership, completionHasGallery)),
|
||||
[uploads, ownership, completionHasGallery],
|
||||
);
|
||||
|
||||
const extrasUploads = useMemo(
|
||||
() =>
|
||||
boardMediaItemsToMediaFiles(detailForRow?.media ?? []).filter((item) =>
|
||||
isExtrasSurfaceMedia(item, ownership),
|
||||
isExtrasSurfaceMedia(item, ownership, completionHasGallery),
|
||||
),
|
||||
[detailForRow?.media, ownership],
|
||||
[detailForRow?.media, ownership, completionHasGallery],
|
||||
);
|
||||
|
||||
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ type EditableWONumberCellProps = {
|
|||
onSave: (v: string) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
locked?: boolean;
|
||||
/** Empty-state label for create flows; renders with a persistent required asterisk. */
|
||||
requiredPlaceholder?: string;
|
||||
};
|
||||
|
||||
async function resolveDuplicateWoNumber(
|
||||
|
|
@ -34,6 +36,28 @@ async function resolveDuplicateWoNumber(
|
|||
return remote && String(remote.id) !== String(currentId) ? remote : undefined;
|
||||
}
|
||||
|
||||
function EmptyWoNumberLabel({
|
||||
value,
|
||||
q,
|
||||
requiredPlaceholder,
|
||||
}: {
|
||||
value: string;
|
||||
q?: string;
|
||||
requiredPlaceholder?: string;
|
||||
}) {
|
||||
if (value) {
|
||||
return <HighlightText text={value} q={q} />;
|
||||
}
|
||||
if (!requiredPlaceholder) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<span className="font-sans text-[13px] font-normal">
|
||||
{requiredPlaceholder} <span style={{ color: "var(--destructive)" }}>*</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableWONumberCell({
|
||||
value,
|
||||
rescheduleCount,
|
||||
|
|
@ -49,6 +73,7 @@ export function EditableWONumberCell({
|
|||
onSave,
|
||||
onDuplicateFound,
|
||||
locked = false,
|
||||
requiredPlaceholder,
|
||||
}: EditableWONumberCellProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
|
@ -162,7 +187,7 @@ export function EditableWONumberCell({
|
|||
padding: "2px 4px",
|
||||
}}
|
||||
>
|
||||
{value ? <HighlightText text={value} q={q} /> : "-"}
|
||||
<EmptyWoNumberLabel value={value} q={q} requiredPlaceholder={requiredPlaceholder} />
|
||||
</span>
|
||||
)}
|
||||
<AddOnPill when={isAddOn} />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ type SiteDialogPocSectionProps = {
|
|||
pocNotes: string;
|
||||
additionalContacts: WorkOrderAdditionalContact[];
|
||||
showPrimaryErrors?: boolean;
|
||||
primaryRequired?: boolean;
|
||||
onPocNameChange: (value: string) => void;
|
||||
onPocPhoneChange: (value: string) => void;
|
||||
onPocNotesChange: (value: string) => void;
|
||||
|
|
@ -19,6 +20,7 @@ export function SiteDialogPocSection({
|
|||
pocNotes,
|
||||
additionalContacts,
|
||||
showPrimaryErrors = false,
|
||||
primaryRequired = false,
|
||||
onPocNameChange,
|
||||
onPocPhoneChange,
|
||||
onPocNotesChange,
|
||||
|
|
@ -31,6 +33,7 @@ export function SiteDialogPocSection({
|
|||
pocNotes={pocNotes}
|
||||
additionalContacts={additionalContacts}
|
||||
showPrimaryErrors={showPrimaryErrors}
|
||||
primaryRequired={primaryRequired}
|
||||
onPocNameChange={onPocNameChange}
|
||||
onPocPhoneChange={onPocPhoneChange}
|
||||
onPocNotesChange={onPocNotesChange}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export function SiteDialog({
|
|||
|
||||
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
|
||||
<div>
|
||||
<FieldLabel>Site</FieldLabel>
|
||||
<FieldLabel required={createMode}>Site</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={s.locId}
|
||||
placeholder="Select site …"
|
||||
|
|
@ -130,6 +130,7 @@ export function SiteDialog({
|
|||
pocNotes={s.notes}
|
||||
additionalContacts={s.extraContacts}
|
||||
showPrimaryErrors={createMode && s.showErrors}
|
||||
primaryRequired={createMode}
|
||||
onPocNameChange={s.setPn}
|
||||
onPocPhoneChange={s.setPp}
|
||||
onPocNotesChange={s.setNotes}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import {
|
|||
tableRowToClosabilityInput,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
BOARD_INLINE_STATUSES,
|
||||
PAST_DUE_BADGE_TOOLTIP,
|
||||
PAST_DUE_POPOVER_BODY,
|
||||
PAST_DUE_RESCHEDULE_CTA,
|
||||
getInlineStatusTransitions,
|
||||
getStatusOptionState,
|
||||
} from "@/domain/work-orders/utils/wo-lifecycle-transitions";
|
||||
import { HoverChevron } from "./hover-chevron";
|
||||
|
|
@ -27,6 +27,7 @@ type StatusCellProps = {
|
|||
onPastDueAttempt?: () => void;
|
||||
onRequestComplete?: () => void;
|
||||
readOnly?: boolean;
|
||||
/** Explicit list for inline create; existing WOs derive valid transitions from `wo.status`. */
|
||||
statusOptions?: readonly WizardWOStatus[];
|
||||
};
|
||||
|
||||
|
|
@ -128,10 +129,11 @@ export function StatusCell({
|
|||
onPastDueAttempt,
|
||||
onRequestComplete,
|
||||
readOnly = false,
|
||||
statusOptions = BOARD_INLINE_STATUSES,
|
||||
statusOptions,
|
||||
}: StatusCellProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const closabilityGaps = rowClosabilityGaps(wo);
|
||||
const options = statusOptions ?? getInlineStatusTransitions(wo.status);
|
||||
|
||||
if (readOnly) {
|
||||
return <ReadOnlyStatusLabel status={wo.status} />;
|
||||
|
|
@ -169,7 +171,7 @@ export function StatusCell({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
slotProps={{ paper: { className: "min-w-[190px] rounded-lg border border-border p-1" } }}
|
||||
>
|
||||
{statusOptions.map((status) => {
|
||||
{options.map((status) => {
|
||||
const option = getStatusOptionState(wo, status, closabilityGaps);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export function WoTableInlineIdentityCells({
|
|||
allOrders={allRows as never[]}
|
||||
onSave={(v) => onPatch({ woNumber: v })}
|
||||
onDuplicateFound={onDuplicateFound}
|
||||
requiredPlaceholder="Insert WO #"
|
||||
/>
|
||||
<AddOnOverrideToggle
|
||||
checked={draft.isAddOnOverride}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function WoTableInlineRowActions({
|
|||
onCancel,
|
||||
}: WoTableInlineRowActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex items-center gap-0.5" style={{ minHeight: 34 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RefObject } from "react";
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import type {
|
||||
LocationOption,
|
||||
VendorDropdownItem,
|
||||
|
|
@ -21,6 +21,7 @@ import { StatusCell } from "./cells/status-cell";
|
|||
import { TypeCell } from "./cells/type-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
|
||||
import { InlineCreateField } from "./inline-create-field";
|
||||
import { toInlineDraftRow } from "./to-inline-draft-row";
|
||||
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
|
||||
import {
|
||||
|
|
@ -47,6 +48,17 @@ type WoTableInlineRowCellsProps = {
|
|||
onRequestComplete?: () => void;
|
||||
};
|
||||
|
||||
/** Matches the identity cells: top-aligned so field boxes and error helpers line up. */
|
||||
const INLINE_FIELD_TD_STYLE = { padding: "8px 6px 0", verticalAlign: "top" } as const;
|
||||
|
||||
function InlineFieldCell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<td className="group/cell" style={INLINE_FIELD_TD_STYLE}>
|
||||
<InlineCreateField>{children}</InlineCreateField>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineTypeSeverityCell({
|
||||
draft,
|
||||
onPatch,
|
||||
|
|
@ -55,20 +67,22 @@ function InlineTypeSeverityCell({
|
|||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||
}) {
|
||||
return (
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<InlineFieldCell>
|
||||
<TypeCell
|
||||
value={draft.type}
|
||||
severity={draft.severity}
|
||||
onSave={(t, sev) => onPatch({ type: t, severity: sev })}
|
||||
/>
|
||||
</td>
|
||||
</InlineFieldCell>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineMutedDashCell() {
|
||||
return (
|
||||
<td style={{ padding: "0 14px" }}>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||
<td style={INLINE_FIELD_TD_STYLE}>
|
||||
<div className="flex items-center" style={{ minHeight: 34 }}>
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
|
@ -85,13 +99,12 @@ function InlineScheduleOnCell({
|
|||
const hasSchedule =
|
||||
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
|
||||
return (
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<InlineFieldCell>
|
||||
<DateCell
|
||||
value={draft.scheduledOn}
|
||||
allowWeekMode
|
||||
targetWeek={draft.targetWeek}
|
||||
defaultMode={weekDefault ? "week" : "date"}
|
||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||
scheduleEnd={draft.scheduleEnd}
|
||||
schedMode={draft.schedMode}
|
||||
onSave={(date) =>
|
||||
|
|
@ -149,7 +162,7 @@ function InlineScheduleOnCell({
|
|||
: undefined
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</InlineFieldCell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -191,27 +204,26 @@ export function WoTableInlineRowCells({
|
|||
|
||||
<InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<InlineFieldCell>
|
||||
<AssignedToCell
|
||||
dispatcherId={draft.dispatcherId}
|
||||
users={users}
|
||||
highlight={!draft.dispatcherId}
|
||||
onSave={(id) => {
|
||||
onPatch({ dispatcherId: id, status: draft.status || "Incomplete" });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</InlineFieldCell>
|
||||
<InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} />
|
||||
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
<InlineFieldCell>
|
||||
<DueDateCell
|
||||
type={draft.type}
|
||||
value={draft.dueDate}
|
||||
minDate={resolveDueDateFloor(draft)}
|
||||
onSave={(date) => onPatch({ dueDate: date })}
|
||||
/>
|
||||
</td>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
</InlineFieldCell>
|
||||
<InlineFieldCell>
|
||||
<PMTypeCell
|
||||
value={draft.pm}
|
||||
pmTypes={pmTypes}
|
||||
|
|
@ -229,8 +241,8 @@ export function WoTableInlineRowCells({
|
|||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
</InlineFieldCell>
|
||||
<InlineFieldCell>
|
||||
<VendorCell
|
||||
vendorId={draft.vendorId}
|
||||
company={draft.company}
|
||||
|
|
@ -239,8 +251,8 @@ export function WoTableInlineRowCells({
|
|||
vendors={vendors}
|
||||
onSave={(p) => onPatch(toVendorTablePatch(p))}
|
||||
/>
|
||||
</td>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
</InlineFieldCell>
|
||||
<InlineFieldCell>
|
||||
<ApptTimeCell
|
||||
apptTime={
|
||||
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
|
||||
|
|
@ -250,8 +262,8 @@ export function WoTableInlineRowCells({
|
|||
onPatch({ apptStart: start ?? "", apptEnd: end ?? "" });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||
</InlineFieldCell>
|
||||
<InlineFieldCell>
|
||||
<StatusCell
|
||||
wo={draftRow as never}
|
||||
statusOptions={CREATION_INLINE_STATUSES}
|
||||
|
|
@ -259,7 +271,7 @@ export function WoTableInlineRowCells({
|
|||
onOpenSlideOver={() => {}}
|
||||
onRequestComplete={onRequestComplete}
|
||||
/>
|
||||
</td>
|
||||
</InlineFieldCell>
|
||||
|
||||
<InlineMutedDashCell />
|
||||
<InlineMutedDashCell />
|
||||
|
|
|
|||
|
|
@ -115,15 +115,17 @@ export function WoTableInlineRow({
|
|||
pointerEvents: saving ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "0 0 0 6px" }} />
|
||||
<td style={{ padding: "8px 0 0 6px", verticalAlign: "top" }} />
|
||||
|
||||
<td style={{ padding: "0 2px 0 8px" }}>
|
||||
<Flag
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden="true"
|
||||
style={{ color: "var(--color-border)" }}
|
||||
/>
|
||||
<td style={{ padding: "8px 2px 0 8px", verticalAlign: "top" }}>
|
||||
<div className="flex items-center" style={{ minHeight: 34 }}>
|
||||
<Flag
|
||||
size={14}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden="true"
|
||||
style={{ color: "var(--color-border)" }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<WoTableInlineRowCells
|
||||
|
|
@ -145,7 +147,7 @@ export function WoTableInlineRow({
|
|||
onRequestComplete={() => setConfirmComplete(true)}
|
||||
/>
|
||||
|
||||
<td style={{ padding: "0 4px" }}>
|
||||
<td style={{ padding: "8px 4px 0", verticalAlign: "top" }}>
|
||||
<WoTableInlineRowActions
|
||||
canSave={canSave}
|
||||
saving={saving}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useId } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
|
@ -23,6 +24,8 @@ type PocContactFieldsProps = {
|
|||
onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
|
||||
helperText?: string;
|
||||
showPrimaryErrors?: boolean;
|
||||
/** Labels the primary name/phone inputs with a persistent required asterisk. */
|
||||
primaryRequired?: boolean;
|
||||
};
|
||||
|
||||
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
|
||||
|
|
@ -135,7 +138,9 @@ export function PocContactFields({
|
|||
onAdditionalContactsChange,
|
||||
helperText,
|
||||
showPrimaryErrors = false,
|
||||
primaryRequired = false,
|
||||
}: PocContactFieldsProps) {
|
||||
const fieldId = useId();
|
||||
const nameMissing = showPrimaryErrors && !pocName.trim();
|
||||
const phoneMissing = showPrimaryErrors && !pocPhone.trim();
|
||||
|
||||
|
|
@ -153,7 +158,13 @@ export function PocContactFields({
|
|||
)}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
{primaryRequired && (
|
||||
<FieldLabel required htmlFor={`${fieldId}-name`}>
|
||||
POC name
|
||||
</FieldLabel>
|
||||
)}
|
||||
<input
|
||||
id={`${fieldId}-name`}
|
||||
value={pocName}
|
||||
onChange={(event) => onPocNameChange(event.target.value)}
|
||||
placeholder="POC name"
|
||||
|
|
@ -168,7 +179,13 @@ export function PocContactFields({
|
|||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
{primaryRequired && (
|
||||
<FieldLabel required htmlFor={`${fieldId}-phone`}>
|
||||
POC phone
|
||||
</FieldLabel>
|
||||
)}
|
||||
<input
|
||||
id={`${fieldId}-phone`}
|
||||
value={pocPhone}
|
||||
onChange={(event) => onPocPhoneChange(event.target.value)}
|
||||
placeholder="POC phone"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function WizardStepLocationPocFields({
|
|||
onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)}
|
||||
helperText="Auto-filled from records — edit if outdated"
|
||||
showPrimaryErrors={showErrors && pocIncomplete}
|
||||
primaryRequired
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,24 +39,53 @@ function formatSeverityAuditLabel(raw: string): string {
|
|||
return raw;
|
||||
}
|
||||
|
||||
function tryTypeRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const CLEARED_AUDIT_VALUE: FormattedAuditValue = { text: "—", isQuotedList: false };
|
||||
|
||||
/** `from X to Y` whenever the previous value is known; a cleared value reads as "—". */
|
||||
function formatFieldChangeSentence(
|
||||
label: string,
|
||||
oldValue: FormattedAuditValue,
|
||||
newValue: FormattedAuditValue,
|
||||
): string | null {
|
||||
if (!oldValue.text && !newValue.text) return null;
|
||||
const next = wrapAuditValue(newValue.text ? newValue : CLEARED_AUDIT_VALUE);
|
||||
if (oldValue.text) {
|
||||
return `Changed ${label} from ${wrapAuditValue(oldValue)} to ${next}`;
|
||||
}
|
||||
return `Changed ${label} to ${next}`;
|
||||
}
|
||||
|
||||
function tryTypeRule({
|
||||
fieldKey,
|
||||
actionKey,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: AuditActionRuleContext): string | null {
|
||||
const matches =
|
||||
fieldKey === "type" ||
|
||||
fieldKey === "workordertype" ||
|
||||
fieldKey === "wotype" ||
|
||||
actionKey === "typechanged" ||
|
||||
actionKey.includes("workordertype");
|
||||
if (!matches || !newValue.text) return null;
|
||||
if (!matches) return null;
|
||||
|
||||
return `Changed Type to ${wrapAuditValue(newValue)}`;
|
||||
return formatFieldChangeSentence("Type", oldValue, newValue);
|
||||
}
|
||||
|
||||
function trySeverityRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
|
||||
if (!matches || !newValue.text) return null;
|
||||
function severityValue(value: FormattedAuditValue): FormattedAuditValue {
|
||||
return value.text ? { text: formatSeverityAuditLabel(value.text), isQuotedList: false } : value;
|
||||
}
|
||||
|
||||
const labeled = { text: formatSeverityAuditLabel(newValue.text), isQuotedList: false };
|
||||
return `Changed Severity to ${wrapAuditValue(labeled)}`;
|
||||
function trySeverityRule({
|
||||
fieldKey,
|
||||
actionKey,
|
||||
oldValue,
|
||||
newValue,
|
||||
}: AuditActionRuleContext): string | null {
|
||||
const matches = fieldKey.includes("severity") || actionKey.includes("severity");
|
||||
if (!matches) return null;
|
||||
|
||||
return formatFieldChangeSentence("Severity", severityValue(oldValue), severityValue(newValue));
|
||||
}
|
||||
|
||||
function tryStatusRule({
|
||||
|
|
|
|||
24
src/domain/work-orders/utils/format-audit-timestamp.ts
Normal file
24
src/domain/work-orders/utils/format-audit-timestamp.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const AUDIT_DATE_PATTERN = /\b\d{1,2}\/\d{1,2}(\/\d{2,4})?\b/;
|
||||
|
||||
/**
|
||||
* Every entry, manual or system, shows a complete timestamp — `09/15/2026 · 7:42 PM`.
|
||||
* Pre-formatted API strings are kept only when they already carry a calendar date.
|
||||
*/
|
||||
export function formatAuditTimestamp(value: string): string {
|
||||
if (!value) return "";
|
||||
if (AUDIT_DATE_PATTERN.test(value) && /\b(AM|PM)\b/i.test(value)) return value;
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
const datePart = date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
const timePart = date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `${datePart} · ${timePart}`;
|
||||
}
|
||||
|
|
@ -127,6 +127,10 @@ function formatGenericAuditFallback(args: {
|
|||
if (newValue.text) {
|
||||
return `Changed ${label} to ${wrapAuditValue(newValue)}`;
|
||||
}
|
||||
if (action) return action;
|
||||
if (oldValue.text) {
|
||||
return `Changed ${label} from ${wrapAuditValue(oldValue)} to "—"`;
|
||||
}
|
||||
// Never surface a raw audit enum such as "FieldChanged" as the sentence.
|
||||
if (fieldName && action) return `Updated ${label}`;
|
||||
return "Updated work order";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ const EMPTY_OWNERSHIP: MediaSurfaceOwnership = {
|
|||
extraOwnedIds: new Set(),
|
||||
};
|
||||
|
||||
/** Only Reactive and Emergency work orders render a media gallery on the Completion tab. */
|
||||
export function hasCompletionMediaGallery(type: string | null | undefined): boolean {
|
||||
return type === "Emergency" || type === "Reactive";
|
||||
}
|
||||
|
||||
function isPhotoOrVideo(item: MediaFile): boolean {
|
||||
return item.fileType === "image" || item.fileType === "video";
|
||||
}
|
||||
|
|
@ -24,20 +29,25 @@ function isInFlight(item: MediaFile): boolean {
|
|||
export function isCompletionSurfaceMedia(
|
||||
item: MediaFile,
|
||||
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
|
||||
completionHasGallery = true,
|
||||
): boolean {
|
||||
if (isInFlight(item)) return true;
|
||||
if (COMPLETION_PURPOSES.has(item.purpose ?? "")) return true;
|
||||
if (!completionHasGallery && item.purpose === "Extra") return false;
|
||||
if (ownership.completionOwnedIds.has(item.id)) return true;
|
||||
if (ownership.extraOwnedIds.has(item.id)) return false;
|
||||
return item.purpose === "Extra" && isPhotoOrVideo(item);
|
||||
}
|
||||
|
||||
/** Without a Completion gallery, every Extra item (including photos/videos) belongs to Extras. */
|
||||
export function isExtrasSurfaceMedia(
|
||||
item: MediaFile,
|
||||
ownership: MediaSurfaceOwnership = EMPTY_OWNERSHIP,
|
||||
completionHasGallery = true,
|
||||
): boolean {
|
||||
if (item.purpose === "Aveta") return true;
|
||||
if (item.purpose !== "Extra") return false;
|
||||
if (!completionHasGallery) return true;
|
||||
if (ownership.completionOwnedIds.has(item.id)) return false;
|
||||
if (ownership.extraOwnedIds.has(item.id)) return true;
|
||||
return !isPhotoOrVideo(item);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,21 @@ import {
|
|||
UPLIFT_PENDING_COMPLETED_TITLE,
|
||||
} from "@/domain/work-orders/utils/wo-closability";
|
||||
|
||||
/** AAP `ALL_STATUSES` — Canceled is kebab-only. */
|
||||
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES;
|
||||
/**
|
||||
* Lifecycle states the server derives (Incomplete from create, Scheduled from Schedule On).
|
||||
* Past Due is an overlay, not a status. None of these may be picked manually on an existing WO.
|
||||
*/
|
||||
export const AUTOMATIC_LIFECYCLE_STATUSES: readonly WizardWOStatus[] = ["Incomplete", "Scheduled"];
|
||||
|
||||
/** Manually selectable statuses for an existing WO — Canceled is kebab-only. */
|
||||
export const BOARD_INLINE_STATUSES: readonly WizardWOStatus[] = ALL_WIZARD_STATUSES.filter(
|
||||
(status) => !AUTOMATIC_LIFECYCLE_STATUSES.includes(status),
|
||||
);
|
||||
|
||||
/** Inline options for an existing WO never repeat its current status. */
|
||||
export function getInlineStatusTransitions(currentStatus: string): WizardWOStatus[] {
|
||||
return BOARD_INLINE_STATUSES.filter((status) => status !== currentStatus);
|
||||
}
|
||||
|
||||
/** AAP `CREATION_STATUSES` — wizard Step 3 and inline create. */
|
||||
export const CREATION_INLINE_STATUSES: readonly WizardWOStatus[] = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
|
||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
function statusEntry(overrides: Partial<WorkOrderAuditEntry> = {}): WorkOrderAuditEntry {
|
||||
return {
|
||||
id: "s1",
|
||||
fieldName: "LifecycleStatus",
|
||||
oldValue: "Pending",
|
||||
newValue: "En Route",
|
||||
action: "StatusChanged",
|
||||
createdAt: "2026-09-15T19:42:00",
|
||||
userName: "Alex Dispatcher",
|
||||
type: "manual",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SlideOverAuditList timestamps (SH-361)", () => {
|
||||
it("shows the date as well as the time on a manual status change", () => {
|
||||
render(<SlideOverAuditList entries={[statusEntry()]} />);
|
||||
|
||||
expect(screen.getByText('Changed status from "Pending" to "En Route"')).toBeInTheDocument();
|
||||
expect(screen.getByText(/^\d{2}\/\d{2}\/2026 · 7:42 PM$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a type correction with its previous value (SH-363)", () => {
|
||||
render(
|
||||
<SlideOverAuditList
|
||||
entries={[
|
||||
statusEntry({
|
||||
id: "t1",
|
||||
action: "FieldChanged",
|
||||
fieldName: "WorkOrderType",
|
||||
oldValue: "PM",
|
||||
newValue: "Reactive",
|
||||
}),
|
||||
statusEntry({
|
||||
id: "t2",
|
||||
action: "FieldChanged",
|
||||
fieldName: "Severity",
|
||||
oldValue: "3",
|
||||
newValue: "",
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Changed Type from "PM" to "Reactive"')).toBeInTheDocument();
|
||||
expect(screen.getByText('Changed Severity from "SEV 3" to "—"')).toBeInTheDocument();
|
||||
expect(screen.queryByText("FieldChanged")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -161,4 +161,40 @@ describe("SlideOverExtrasTab Aveta", () => {
|
|||
|
||||
expect(screen.getByRole("button", { name: "safety-form.pdf" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("labels each extra item with its type and category", () => {
|
||||
const sitePhoto: MediaFile = {
|
||||
id: "m3",
|
||||
name: "site.jpg",
|
||||
fileType: "image",
|
||||
url: "blob:3",
|
||||
category: "Photo",
|
||||
purpose: "Extra",
|
||||
};
|
||||
const walkthrough: MediaFile = {
|
||||
id: "m4",
|
||||
name: "walkthrough.mp4",
|
||||
fileType: "video",
|
||||
url: "blob:4",
|
||||
category: "Video",
|
||||
purpose: "Extra",
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<SlideOverExtrasTab
|
||||
uploads={[sitePhoto, walkthrough, avetaDoc]}
|
||||
isReadOnly
|
||||
onUpload={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("group", { name: "Photo, Extra: site.jpg" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("group", { name: "Video, Extra: walkthrough.mp4" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("group", { name: "PDF, Aveta: aveta-clearance.pdf" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
const incompleteRow = {
|
||||
|
|
@ -43,21 +44,62 @@ describe("StatusCell closability gate", () => {
|
|||
expect(screen.queryByRole("button", { name: /^canceled$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("disables Scheduled when the row has no concrete date", () => {
|
||||
it.each<WizardWOStatus>([
|
||||
"Pending",
|
||||
"En Route",
|
||||
"On Site",
|
||||
"In Progress",
|
||||
"Rescheduled",
|
||||
"Pending Quote",
|
||||
])("offers only manual transitions away from %s (SH-357, SH-358)", (current) => {
|
||||
const onChangeStatus = vi.fn();
|
||||
render(
|
||||
<StatusCell
|
||||
wo={{ ...incompleteRow, status: "Pending", scheduledOn: "" }}
|
||||
wo={{ ...incompleteRow, status: current, scheduledOn: "2026-06-01" }}
|
||||
onChangeStatus={onChangeStatus}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /pending/i }));
|
||||
const scheduled = screen.getByRole("button", { name: /^scheduled$/i });
|
||||
expect(scheduled).toBeDisabled();
|
||||
fireEvent.click(scheduled);
|
||||
expect(onChangeStatus).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") }));
|
||||
const offered = Array.from(document.querySelectorAll(".MuiPopover-paper button")).map(
|
||||
(button) => button.textContent?.trim(),
|
||||
);
|
||||
|
||||
expect(offered).not.toContain("Incomplete");
|
||||
expect(offered).not.toContain("Scheduled");
|
||||
expect(offered).not.toContain("Past Due");
|
||||
expect(offered).not.toContain("Canceled");
|
||||
expect(offered).not.toContain(current);
|
||||
expect(offered).toEqual(
|
||||
[
|
||||
"Pending",
|
||||
"En Route",
|
||||
"On Site",
|
||||
"In Progress",
|
||||
"Completed",
|
||||
"Rescheduled",
|
||||
"Pending Quote",
|
||||
].filter((status) => status !== current),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers every manual status, and no automatic one, from Incomplete or Scheduled", () => {
|
||||
for (const current of ["Incomplete", "Scheduled"] as const) {
|
||||
const { unmount } = render(
|
||||
<StatusCell
|
||||
wo={{ ...incompleteRow, status: current }}
|
||||
onChangeStatus={vi.fn()}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(current, "i") }));
|
||||
expect(screen.getByRole("button", { name: /^pending$/i })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /^incomplete$/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^scheduled$/i })).toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("limits creation options to Incomplete, Pending, and Scheduled", () => {
|
||||
|
|
|
|||
|
|
@ -278,4 +278,53 @@ describe("useSlideOverMediaState completion filter", () => {
|
|||
expect(remounted.result.current.completionMedia).toHaveLength(0);
|
||||
expect(remounted.result.current.extrasUploads.map((item) => item.id)).toEqual(["10"]);
|
||||
});
|
||||
|
||||
it("shows Extra photos and videos in Extra Docs on PM work orders", () => {
|
||||
const detail = detailWithMedia(
|
||||
[
|
||||
{
|
||||
id: "10",
|
||||
category: "Extra",
|
||||
url: "https://cdn.example.com/site.jpg",
|
||||
fileName: "site.jpg",
|
||||
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||
isLegacy: false,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
category: "Extra",
|
||||
url: "https://cdn.example.com/walkthrough.mp4",
|
||||
fileName: "walkthrough.mp4",
|
||||
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||
isLegacy: false,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
category: "Extra",
|
||||
url: "https://cdn.example.com/extra.pdf",
|
||||
fileName: "extra.pdf",
|
||||
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||
isLegacy: false,
|
||||
},
|
||||
],
|
||||
{ id: 200, type: "PM" },
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useSlideOverMediaState({
|
||||
detailForRow: detail,
|
||||
uploadMediaMutation: idleMutation(),
|
||||
deleteMediaMutation: idleMutation(),
|
||||
categorizeMediaMutation: idleMutation(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.completionMedia).toHaveLength(0);
|
||||
expect(result.current.extrasUploads.map((item) => item.id)).toEqual(["10", "11", "4"]);
|
||||
expect(result.current.extrasUploads.map((item) => item.fileType)).toEqual([
|
||||
"image",
|
||||
"video",
|
||||
"pdf",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields";
|
||||
import { WoTableInlineRow } from "@/app/(protected)/workorders/_components/list/table/wo-table-inline-row";
|
||||
import {
|
||||
EMPTY_TABLE_DRAFT,
|
||||
type WorkOrderTableDraft,
|
||||
} from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/type-cell", () => ({
|
||||
TypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell", () => ({
|
||||
AssignedToCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
|
||||
DateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/due-date-cell", () => ({
|
||||
DueDateCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
|
||||
PMTypeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
|
||||
VendorCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
|
||||
ApptTimeCell: () => null,
|
||||
}));
|
||||
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
|
||||
StatusCell: () => null,
|
||||
}));
|
||||
|
||||
function renderInlineRow(draft: WorkOrderTableDraft, onSave = vi.fn()) {
|
||||
const ui = (d: WorkOrderTableDraft) => (
|
||||
<table>
|
||||
<tbody>
|
||||
<WoTableInlineRow
|
||||
draft={d}
|
||||
draftDayKey="2026-06-01"
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
allRows={[]}
|
||||
onChange={vi.fn()}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const view = renderWithProviders(ui(draft), { withAuth: false });
|
||||
return { ...view, rerenderDraft: (d: WorkOrderTableDraft) => view.rerender(ui(d)) };
|
||||
}
|
||||
|
||||
const COMPLETE_DRAFT: WorkOrderTableDraft = {
|
||||
...EMPTY_TABLE_DRAFT,
|
||||
site: "BK5",
|
||||
locationId: 9,
|
||||
woNumber: "12345678901",
|
||||
pocName: "Dana",
|
||||
pocPhone: "555-0100",
|
||||
};
|
||||
|
||||
describe("WoTableInlineRow required fields", () => {
|
||||
it("marks Site and WO # as required without showing errors before a Save attempt", () => {
|
||||
renderInlineRow(EMPTY_TABLE_DRAFT);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Site \*$/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Insert WO #")).toHaveTextContent("Insert WO # *");
|
||||
expect(screen.queryByText("Site is required")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals field errors only after Save is attempted, then clears them once corrected", () => {
|
||||
const onSave = vi.fn();
|
||||
const { rerenderDraft } = renderInlineRow(EMPTY_TABLE_DRAFT, onSave);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save work order" }));
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Site is required")).toBeInTheDocument();
|
||||
expect(screen.getByText("Work order number is required")).toBeInTheDocument();
|
||||
|
||||
rerenderDraft(COMPLETE_DRAFT);
|
||||
|
||||
expect(screen.queryByText("Site is required")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Work order number is required")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save work order" }));
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PocContactFields required primary contact", () => {
|
||||
it("labels POC name and phone with a required asterisk when the contact is required", () => {
|
||||
renderWithProviders(
|
||||
<PocContactFields
|
||||
pocName=""
|
||||
pocPhone=""
|
||||
pocNotes=""
|
||||
additionalContacts={[]}
|
||||
primaryRequired
|
||||
onPocNameChange={vi.fn()}
|
||||
onPocPhoneChange={vi.fn()}
|
||||
onPocNotesChange={vi.fn()}
|
||||
onAdditionalContactsChange={vi.fn()}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/POC name/)).toHaveAttribute("placeholder", "POC name");
|
||||
expect(screen.getByLabelText(/POC phone/)).toHaveAttribute("placeholder", "POC phone");
|
||||
expect(screen.getByText("POC name").closest("label")).toHaveTextContent("POC name*");
|
||||
expect(screen.getByText("POC phone").closest("label")).toHaveTextContent("POC phone*");
|
||||
expect(screen.queryByText("Contact name is required")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatAuditTimestamp } from "@/domain/work-orders/utils/format-audit-timestamp";
|
||||
|
||||
describe("formatAuditTimestamp (SH-361)", () => {
|
||||
it("includes the calendar date and the time for an ISO audit time", () => {
|
||||
const iso = "2026-09-15T19:42:00";
|
||||
const date = new Date(iso);
|
||||
const expectedDate = date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const formatted = formatAuditTimestamp(iso);
|
||||
|
||||
expect(formatted).toBe(`${expectedDate} · 7:42 PM`);
|
||||
expect(formatted).toMatch(/^\d{2}\/\d{2}\/\d{4} · \d{1,2}:\d{2} (AM|PM)$/);
|
||||
});
|
||||
|
||||
it("distinguishes the same time of day on different dates", () => {
|
||||
expect(formatAuditTimestamp("2026-09-14T09:48:00")).not.toBe(
|
||||
formatAuditTimestamp("2026-09-15T09:48:00"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps pre-formatted or unparseable strings unchanged", () => {
|
||||
expect(formatAuditTimestamp("· 7:42 PM")).toBe("· 7:42 PM");
|
||||
expect(formatAuditTimestamp("09/15/2026 · 7:42 PM")).toBe("09/15/2026 · 7:42 PM");
|
||||
});
|
||||
|
||||
it("returns an empty string when there is no time", () => {
|
||||
expect(formatAuditTimestamp("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
@ -89,26 +89,67 @@ describe("formatBoardAuditAction", () => {
|
|||
).toBe("WO marked as Past Due. Schedule On has passed without completion.");
|
||||
});
|
||||
|
||||
it("formats type correction as Changed Type to (AAP, new value only)", () => {
|
||||
it("formats type correction with the previous and new type (SH-363)", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldPatched",
|
||||
action: "FieldChanged",
|
||||
fieldName: "WorkOrderType",
|
||||
oldValue: "PM",
|
||||
newValue: "Reactive",
|
||||
}),
|
||||
).toBe('Changed Type from "PM" to "Reactive"');
|
||||
});
|
||||
|
||||
it("formats a first type assignment without a from clause", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldChanged",
|
||||
fieldName: "WorkOrderType",
|
||||
oldValue: "",
|
||||
newValue: "Emergency",
|
||||
}),
|
||||
).toBe('Changed Type to "Emergency"');
|
||||
});
|
||||
|
||||
it("formats severity as Changed Severity to SEV N", () => {
|
||||
it("formats severity with the previous and new SEV labels", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldPatched",
|
||||
action: "FieldChanged",
|
||||
fieldName: "Severity",
|
||||
oldValue: "3",
|
||||
newValue: "1",
|
||||
}),
|
||||
).toBe('Changed Severity to "SEV 1"');
|
||||
).toBe('Changed Severity from "SEV 3" to "SEV 1"');
|
||||
});
|
||||
|
||||
it("formats severity cleared by a type correction instead of a raw FieldChanged (SH-363)", () => {
|
||||
expect(
|
||||
formatBoardAuditAction({
|
||||
action: "FieldChanged",
|
||||
fieldName: "Severity",
|
||||
oldValue: "3",
|
||||
newValue: "",
|
||||
}),
|
||||
).toBe('Changed Severity from "SEV 3" to "—"');
|
||||
});
|
||||
|
||||
it("never renders a raw audit action enum for a cleared unknown field", () => {
|
||||
const cleared = formatBoardAuditAction({
|
||||
action: "FieldChanged",
|
||||
fieldName: "VendorNte",
|
||||
oldValue: "100",
|
||||
newValue: "",
|
||||
});
|
||||
const empty = formatBoardAuditAction({
|
||||
action: "FieldChanged",
|
||||
fieldName: "VendorNte",
|
||||
oldValue: "",
|
||||
newValue: "",
|
||||
});
|
||||
|
||||
expect(cleared).toBe('Changed vendor nte from "100" to "—"');
|
||||
expect(empty).toBe("Updated vendor nte");
|
||||
expect([cleared, empty]).not.toContain("FieldChanged");
|
||||
});
|
||||
|
||||
it("falls back to from → to for unknown fields", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
import {
|
||||
hasCompletionMediaGallery,
|
||||
isCompletionSurfaceMedia,
|
||||
isExtrasSurfaceMedia,
|
||||
} from "@/domain/work-orders/utils/partition-slide-over-media";
|
||||
|
|
@ -76,4 +77,33 @@ describe("partition-slide-over-media", () => {
|
|||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("renders a Completion gallery only for Reactive and Emergency work orders", () => {
|
||||
expect(hasCompletionMediaGallery("Reactive")).toBe(true);
|
||||
expect(hasCompletionMediaGallery("Emergency")).toBe(true);
|
||||
expect(hasCompletionMediaGallery("PM")).toBe(false);
|
||||
expect(hasCompletionMediaGallery("Overdue")).toBe(false);
|
||||
expect(hasCompletionMediaGallery("")).toBe(false);
|
||||
});
|
||||
|
||||
it("puts Extra photos and videos on Extra Docs when Completion has no gallery", () => {
|
||||
const photo = file({ id: "10", fileType: "image", purpose: "Extra" });
|
||||
const video = file({ id: "11", fileType: "video", purpose: "Extra", category: "Video" });
|
||||
|
||||
for (const item of [photo, video]) {
|
||||
expect(isCompletionSurfaceMedia(item, undefined, false)).toBe(false);
|
||||
expect(isExtrasSurfaceMedia(item, undefined, false)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores stale completion ownership when Completion has no gallery", () => {
|
||||
const photo = file({ id: "10", fileType: "image", purpose: "Extra" });
|
||||
const ownership = {
|
||||
completionOwnedIds: new Set(["10"]),
|
||||
extraOwnedIds: new Set<string>(),
|
||||
};
|
||||
|
||||
expect(isCompletionSurfaceMedia(photo, ownership, false)).toBe(false);
|
||||
expect(isExtrasSurfaceMedia(photo, ownership, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { PENDING_UPLIFT_GAP } from "@/domain/work-orders/utils/wo-closability";
|
||||
import {
|
||||
AUTOMATIC_LIFECYCLE_STATUSES,
|
||||
BOARD_INLINE_STATUSES,
|
||||
COMPLETED_DOC_BLOCK_MSG,
|
||||
COMPLETED_MEDIA_BLOCK_MSG,
|
||||
CREATION_INLINE_STATUSES,
|
||||
SCHEDULED_NEEDS_DATE_MSG,
|
||||
formatAapCompletedBlockTitle,
|
||||
getInlineStatusTransitions,
|
||||
getStatusOptionState,
|
||||
hasConcreteScheduleDate,
|
||||
maybeAutoSchedulePatch,
|
||||
|
|
@ -17,11 +19,9 @@ import {
|
|||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
||||
describe("wo-lifecycle-transitions", () => {
|
||||
it("lists AAP board statuses without Canceled", () => {
|
||||
it("lists only manual board statuses: no automatic states and no Canceled (SH-169)", () => {
|
||||
expect(BOARD_INLINE_STATUSES).toEqual([
|
||||
"Incomplete",
|
||||
"Pending",
|
||||
"Scheduled",
|
||||
"En Route",
|
||||
"On Site",
|
||||
"In Progress",
|
||||
|
|
@ -30,6 +30,21 @@ describe("wo-lifecycle-transitions", () => {
|
|||
"Pending Quote",
|
||||
]);
|
||||
expect(BOARD_INLINE_STATUSES).not.toContain("Canceled");
|
||||
for (const automatic of AUTOMATIC_LIFECYCLE_STATUSES) {
|
||||
expect(BOARD_INLINE_STATUSES).not.toContain(automatic);
|
||||
}
|
||||
});
|
||||
|
||||
it("never offers the current status as a transition (SH-358)", () => {
|
||||
expect(getInlineStatusTransitions("On Site")).toEqual([
|
||||
"Pending",
|
||||
"En Route",
|
||||
"In Progress",
|
||||
"Completed",
|
||||
"Rescheduled",
|
||||
"Pending Quote",
|
||||
]);
|
||||
expect(getInlineStatusTransitions("Incomplete")).toEqual(BOARD_INLINE_STATUSES);
|
||||
});
|
||||
|
||||
it("lists AAP creation statuses only", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue