Merge branch 'dev' into feat/ab/sh-195-date-header-tooltips

This commit is contained in:
Alexandre Brandizzi 2026-09-16 19:04:26 -03:00 • committed by GitHub
commit 0ca032ef8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 216 additions and 33 deletions

View file

@ -22,6 +22,8 @@ type EditableWONumberCellProps = {
onSave: (v: string) => void; onSave: (v: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void; onDuplicateFound?: (existing: WorkOrderTableRow) => void;
locked?: boolean; locked?: boolean;
/** Empty-state label for create flows; renders with a persistent required asterisk. */
requiredPlaceholder?: string;
}; };
async function resolveDuplicateWoNumber( async function resolveDuplicateWoNumber(
@ -34,6 +36,28 @@ async function resolveDuplicateWoNumber(
return remote && String(remote.id) !== String(currentId) ? remote : undefined; 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({ export function EditableWONumberCell({
value, value,
rescheduleCount, rescheduleCount,
@ -49,6 +73,7 @@ export function EditableWONumberCell({
onSave, onSave,
onDuplicateFound, onDuplicateFound,
locked = false, locked = false,
requiredPlaceholder,
}: EditableWONumberCellProps) { }: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
@ -162,7 +187,7 @@ export function EditableWONumberCell({
padding: "2px 4px", padding: "2px 4px",
}} }}
> >
{value ? <HighlightText text={value} q={q} /> : "-"} <EmptyWoNumberLabel value={value} q={q} requiredPlaceholder={requiredPlaceholder} />
</span> </span>
)} )}
<AddOnPill when={isAddOn} /> <AddOnPill when={isAddOn} />

View file

@ -7,6 +7,7 @@ type SiteDialogPocSectionProps = {
pocNotes: string; pocNotes: string;
additionalContacts: WorkOrderAdditionalContact[]; additionalContacts: WorkOrderAdditionalContact[];
showPrimaryErrors?: boolean; showPrimaryErrors?: boolean;
primaryRequired?: boolean;
onPocNameChange: (value: string) => void; onPocNameChange: (value: string) => void;
onPocPhoneChange: (value: string) => void; onPocPhoneChange: (value: string) => void;
onPocNotesChange: (value: string) => void; onPocNotesChange: (value: string) => void;
@ -19,6 +20,7 @@ export function SiteDialogPocSection({
pocNotes, pocNotes,
additionalContacts, additionalContacts,
showPrimaryErrors = false, showPrimaryErrors = false,
primaryRequired = false,
onPocNameChange, onPocNameChange,
onPocPhoneChange, onPocPhoneChange,
onPocNotesChange, onPocNotesChange,
@ -31,6 +33,7 @@ export function SiteDialogPocSection({
pocNotes={pocNotes} pocNotes={pocNotes}
additionalContacts={additionalContacts} additionalContacts={additionalContacts}
showPrimaryErrors={showPrimaryErrors} showPrimaryErrors={showPrimaryErrors}
primaryRequired={primaryRequired}
onPocNameChange={onPocNameChange} onPocNameChange={onPocNameChange}
onPocPhoneChange={onPocPhoneChange} onPocPhoneChange={onPocPhoneChange}
onPocNotesChange={onPocNotesChange} onPocNotesChange={onPocNotesChange}

View file

@ -86,7 +86,7 @@ export function SiteDialog({
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0"> <fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
<div> <div>
<FieldLabel>Site</FieldLabel> <FieldLabel required={createMode}>Site</FieldLabel>
<WizardFieldSelect <WizardFieldSelect
value={s.locId} value={s.locId}
placeholder="Select site …" placeholder="Select site …"
@ -130,6 +130,7 @@ export function SiteDialog({
pocNotes={s.notes} pocNotes={s.notes}
additionalContacts={s.extraContacts} additionalContacts={s.extraContacts}
showPrimaryErrors={createMode && s.showErrors} showPrimaryErrors={createMode && s.showErrors}
primaryRequired={createMode}
onPocNameChange={s.setPn} onPocNameChange={s.setPn}
onPocPhoneChange={s.setPp} onPocPhoneChange={s.setPp}
onPocNotesChange={s.setNotes} onPocNotesChange={s.setNotes}

View file

@ -89,6 +89,7 @@ export function WoTableInlineIdentityCells({
allOrders={allRows as never[]} allOrders={allRows as never[]}
onSave={(v) => onPatch({ woNumber: v })} onSave={(v) => onPatch({ woNumber: v })}
onDuplicateFound={onDuplicateFound} onDuplicateFound={onDuplicateFound}
requiredPlaceholder="Insert WO #"
/> />
<AddOnOverrideToggle <AddOnOverrideToggle
checked={draft.isAddOnOverride} checked={draft.isAddOnOverride}

View file

@ -17,7 +17,7 @@ export function WoTableInlineRowActions({
onCancel, onCancel,
}: WoTableInlineRowActionsProps) { }: WoTableInlineRowActionsProps) {
return ( return (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5" style={{ minHeight: 34 }}>
<button <button
type="button" type="button"
onClick={onSave} onClick={onSave}

View file

@ -1,4 +1,4 @@
import type { RefObject } from "react"; import type { ReactNode, RefObject } from "react";
import type { import type {
LocationOption, LocationOption,
VendorDropdownItem, VendorDropdownItem,
@ -21,6 +21,7 @@ import { StatusCell } from "./cells/status-cell";
import { TypeCell } from "./cells/type-cell"; import { TypeCell } from "./cells/type-cell";
import { VendorCell } from "./cells/vendor-cell"; import { VendorCell } from "./cells/vendor-cell";
import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch"; import { toVendorTablePatch } from "@/domain/work-orders/utils/vendor-assignment-patch";
import { InlineCreateField } from "./inline-create-field";
import { toInlineDraftRow } from "./to-inline-draft-row"; import { toInlineDraftRow } from "./to-inline-draft-row";
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells"; import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
@ -43,6 +44,17 @@ type WoTableInlineRowCellsProps = {
onRequestComplete?: () => void; 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({ function InlineTypeSeverityCell({
draft, draft,
onPatch, onPatch,
@ -51,20 +63,22 @@ function InlineTypeSeverityCell({
onPatch: (patch: Partial<WorkOrderTableDraft>) => void; onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
}) { }) {
return ( return (
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineFieldCell>
<TypeCell <TypeCell
value={draft.type} value={draft.type}
severity={draft.severity} severity={draft.severity}
onSave={(t, sev) => onPatch({ type: t, severity: sev })} onSave={(t, sev) => onPatch({ type: t, severity: sev })}
/> />
</td> </InlineFieldCell>
); );
} }
function InlineMutedDashCell() { function InlineMutedDashCell() {
return ( return (
<td style={{ padding: "0 14px" }}> <td style={INLINE_FIELD_TD_STYLE}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span> <div className="flex items-center" style={{ minHeight: 34 }}>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
</div>
</td> </td>
); );
} }
@ -81,13 +95,12 @@ function InlineScheduleOnCell({
const hasSchedule = const hasSchedule =
Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd); Boolean(draft.scheduledOn) || Boolean(draft.targetWeek) || Boolean(draft.scheduleEnd);
return ( return (
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineFieldCell>
<DateCell <DateCell
value={draft.scheduledOn} value={draft.scheduledOn}
allowWeekMode allowWeekMode
targetWeek={draft.targetWeek} targetWeek={draft.targetWeek}
defaultMode={weekDefault ? "week" : "date"} defaultMode={weekDefault ? "week" : "date"}
highlight={!draft.scheduledOn && !draft.targetWeek}
scheduleEnd={draft.scheduleEnd} scheduleEnd={draft.scheduleEnd}
schedMode={draft.schedMode} schedMode={draft.schedMode}
onSave={(date) => onSave={(date) =>
@ -142,7 +155,7 @@ function InlineScheduleOnCell({
: undefined : undefined
} }
/> />
</td> </InlineFieldCell>
); );
} }
@ -184,26 +197,25 @@ export function WoTableInlineRowCells({
<InlineTypeSeverityCell draft={draft} onPatch={onPatch} /> <InlineTypeSeverityCell draft={draft} onPatch={onPatch} />
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineFieldCell>
<AssignedToCell <AssignedToCell
dispatcherId={draft.dispatcherId} dispatcherId={draft.dispatcherId}
users={users} users={users}
highlight={!draft.dispatcherId}
onSave={(id) => { onSave={(id) => {
onPatch({ dispatcherId: id, status: draft.status || "Incomplete" }); onPatch({ dispatcherId: id, status: draft.status || "Incomplete" });
}} }}
/> />
</td> </InlineFieldCell>
<InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} /> <InlineScheduleOnCell draft={draft} weekDefault={weekDefault} onPatch={onPatch} />
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineFieldCell>
<DueDateCell <DueDateCell
type={draft.type} type={draft.type}
value={draft.dueDate} value={draft.dueDate}
onSave={(date) => onPatch({ dueDate: date })} onSave={(date) => onPatch({ dueDate: date })}
/> />
</td> </InlineFieldCell>
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineFieldCell>
<PMTypeCell <PMTypeCell
value={draft.pm} value={draft.pm}
pmTypes={pmTypes} pmTypes={pmTypes}
@ -221,8 +233,8 @@ export function WoTableInlineRowCells({
}) })
} }
/> />
</td> </InlineFieldCell>
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineFieldCell>
<VendorCell <VendorCell
vendorId={draft.vendorId} vendorId={draft.vendorId}
company={draft.company} company={draft.company}
@ -231,8 +243,8 @@ export function WoTableInlineRowCells({
vendors={vendors} vendors={vendors}
onSave={(p) => onPatch(toVendorTablePatch(p))} onSave={(p) => onPatch(toVendorTablePatch(p))}
/> />
</td> </InlineFieldCell>
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineFieldCell>
<ApptTimeCell <ApptTimeCell
apptTime={ apptTime={
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : "" draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
@ -242,8 +254,8 @@ export function WoTableInlineRowCells({
onPatch({ apptStart: start ?? "", apptEnd: end ?? "" }); onPatch({ apptStart: start ?? "", apptEnd: end ?? "" });
}} }}
/> />
</td> </InlineFieldCell>
<td className="group/cell" style={{ padding: "0 14px" }}> <InlineFieldCell>
<StatusCell <StatusCell
wo={draftRow as never} wo={draftRow as never}
statusOptions={CREATION_INLINE_STATUSES} statusOptions={CREATION_INLINE_STATUSES}
@ -251,7 +263,7 @@ export function WoTableInlineRowCells({
onOpenSlideOver={() => {}} onOpenSlideOver={() => {}}
onRequestComplete={onRequestComplete} onRequestComplete={onRequestComplete}
/> />
</td> </InlineFieldCell>
<InlineMutedDashCell /> <InlineMutedDashCell />
<InlineMutedDashCell /> <InlineMutedDashCell />

View file

@ -115,15 +115,17 @@ export function WoTableInlineRow({
pointerEvents: saving ? "none" : undefined, 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" }}> <td style={{ padding: "8px 2px 0 8px", verticalAlign: "top" }}>
<Flag <div className="flex items-center" style={{ minHeight: 34 }}>
size={14} <Flag
strokeWidth={1.75} size={14}
aria-hidden="true" strokeWidth={1.75}
style={{ color: "var(--color-border)" }} aria-hidden="true"
/> style={{ color: "var(--color-border)" }}
/>
</div>
</td> </td>
<WoTableInlineRowCells <WoTableInlineRowCells
@ -145,7 +147,7 @@ export function WoTableInlineRow({
onRequestComplete={() => setConfirmComplete(true)} onRequestComplete={() => setConfirmComplete(true)}
/> />
<td style={{ padding: "0 4px" }}> <td style={{ padding: "8px 4px 0", verticalAlign: "top" }}>
<WoTableInlineRowActions <WoTableInlineRowActions
canSave={canSave} canSave={canSave}
saving={saving} saving={saving}

View file

@ -1,3 +1,4 @@
import { useId } from "react";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
@ -23,6 +24,8 @@ type PocContactFieldsProps = {
onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void; onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
helperText?: string; helperText?: string;
showPrimaryErrors?: boolean; showPrimaryErrors?: boolean;
/** Labels the primary name/phone inputs with a persistent required asterisk. */
primaryRequired?: boolean;
}; };
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean { function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
@ -135,7 +138,9 @@ export function PocContactFields({
onAdditionalContactsChange, onAdditionalContactsChange,
helperText, helperText,
showPrimaryErrors = false, showPrimaryErrors = false,
primaryRequired = false,
}: PocContactFieldsProps) { }: PocContactFieldsProps) {
const fieldId = useId();
const nameMissing = showPrimaryErrors && !pocName.trim(); const nameMissing = showPrimaryErrors && !pocName.trim();
const phoneMissing = showPrimaryErrors && !pocPhone.trim(); const phoneMissing = showPrimaryErrors && !pocPhone.trim();
@ -153,7 +158,13 @@ export function PocContactFields({
)} )}
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
{primaryRequired && (
<FieldLabel required htmlFor={`${fieldId}-name`}>
POC name
</FieldLabel>
)}
<input <input
id={`${fieldId}-name`}
value={pocName} value={pocName}
onChange={(event) => onPocNameChange(event.target.value)} onChange={(event) => onPocNameChange(event.target.value)}
placeholder="POC name" placeholder="POC name"
@ -168,7 +179,13 @@ export function PocContactFields({
</Text> </Text>
</div> </div>
<div> <div>
{primaryRequired && (
<FieldLabel required htmlFor={`${fieldId}-phone`}>
POC phone
</FieldLabel>
)}
<input <input
id={`${fieldId}-phone`}
value={pocPhone} value={pocPhone}
onChange={(event) => onPocPhoneChange(event.target.value)} onChange={(event) => onPocPhoneChange(event.target.value)}
placeholder="POC phone" placeholder="POC phone"

View file

@ -26,6 +26,7 @@ export function WizardStepLocationPocFields({
onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)} onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)}
helperText="Auto-filled from records — edit if outdated" helperText="Auto-filled from records — edit if outdated"
showPrimaryErrors={showErrors && pocIncomplete} showPrimaryErrors={showErrors && pocIncomplete}
primaryRequired
/> />
); );
} }

View file

@ -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();
});
});