diff --git a/CLAUDE.md b/CLAUDE.md index 64e561713..a75e513cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,9 @@ OpenInspection runs as ONE Cloudflare Worker (cloudflare/react-router-hono-fulls | `APP_BASE_URL` | No | Public URL for OAuth and link generation | | `APP_BASE_URL` | No | Public origin used when building absolute links (reports, hosted `/legal/:tenant/…` Privacy & Terms). | | `RESEND_API_KEY`| No | Platform-default email delivery (Resend). Tenants may switch to their OWN Resend key + verified sender via Settings → Communication (per-tenant override; the email pipeline resolves own-vs-platform explicitly). | -| `GEMINI_API_KEY`| No | DEPRECATED as a platform key — AI assistance is strictly bring-your-own-key: `AIService` reads the tenant's own stored key (Settings → Advanced) and ignores this env. AI features stay off until a tenant configures a key. | +| `GEMINI_API_KEY`| No | Not the credential AI features run on. `AIService` resolves credentials per call (`server/lib/ai/resolve-provider.ts`): a tenant's own stored key (Settings → Advanced → AI) always wins, and in `saas` mode a deployment-provided key may be used instead for tenants the deployment grants managed access to. In `standalone` there is no managed path at all — the tenant's key or nothing. This env is still read by the Advanced-settings "Test connection" diagnostic. | +| `AI_MODEL` | No | Model id every AI call uses (e.g. a Gemini model name). **No default is compiled in**: when unset, AI features fail closed with a 503 rather than silently pinning whichever model was current when the code was written. Required for any AI feature to work, in every mode. | +| `AI_MANAGED_API_KEY` | No | Deployment-provided AI key. Used only where `profile.hasManagedAi` is true (`saas`), and only for tenants the deployment grants managed access to; an entitled tenant on a deployment that never provisioned this key gets the feature OFF, not a runtime credential error. Absent in `standalone` by construction rather than disabled by a flag. Usage on this key meters under `ai_translate`/`ai_assist`; usage on a tenant's own key meters under `ai_translate_byo`/`ai_assist_byo` and never counts against a deployment allowance. | | `APP_MODE` | No | `standalone` (default) or `saas` — controls tenant resolution | | `APP_NAME` | No | Custom branding name | | `PRIMARY_COLOR` | No | Custom branding color | diff --git a/app/components/ConfirmDialog.tsx b/app/components/ConfirmDialog.tsx index 8c6a275c5..0602b44d1 100644 --- a/app/components/ConfirmDialog.tsx +++ b/app/components/ConfirmDialog.tsx @@ -1,7 +1,16 @@ import { Modal } from "@core/shared-ui"; +import { m } from "~/paraglide/messages"; +/** + * Both button labels are TRANSLATED, and the confirm label defaults rather than + * being hardcoded. They used to be the bare strings "Cancel" and "Delete" — + * which meant this one component silently shipped untranslated chrome to every + * one of its call sites, in the middle of dialogs whose title and message were + * translated. A shared component is the worst place to leave a literal: it does + * not look like ten omissions, it looks like one. + */ export function ConfirmDialog({ - open, title, message, confirmLabel = "Delete", tone = "danger", busy = false, onConfirm, onCancel, + open, title, message, confirmLabel, tone = "danger", busy = false, onConfirm, onCancel, }: { open: boolean; title: string; @@ -29,7 +38,7 @@ export function ConfirmDialog({ onClick={onCancel} className="px-4 py-2 rounded-md border border-ih-border text-[13px] font-bold text-ih-fg-2 hover:bg-ih-bg-muted transition-colors" > - Cancel + {m.common_cancel()} } diff --git a/app/components/LocaleSwitcher.test.tsx b/app/components/LocaleSwitcher.test.tsx new file mode 100644 index 000000000..247065f5b --- /dev/null +++ b/app/components/LocaleSwitcher.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment happy-dom +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createRoutesStub } from "react-router"; +import { LocaleSwitcher } from "./LocaleSwitcher"; + +/** + * The switcher has to do BOTH writes or it is broken in a way that looks like + * it works. Cookie only: the choice is lost on the next device, and — worse — + * `auth-layout`'s stamp ranks the stored preference above the cookie and + * corrects it straight back on the following navigation. Database only: nothing + * changes until a round trip completes, so the control appears dead. + * + * Rendered through a real router stub rather than bare, so the assertion is on + * what the PROFILE ACTION actually receives — a spy would pass against a + * component that submits to nowhere. + */ +function renderSwitcher(serverLocale: string) { + const submitted: { intent?: string; locale?: string } = {}; + const Stub = createRoutesStub([ + { + id: "root", + path: "/", + loader: () => ({ locale: serverLocale }), + Component: () => , + }, + { + path: "/settings/profile", + action: async ({ request }: { request: Request }) => { + const fd = await request.formData(); + submitted.intent = String(fd.get("intent")); + submitted.locale = String(fd.get("locale")); + return { success: true }; + }, + }, + ]); + render(); + return submitted; +} + +describe("LocaleSwitcher", () => { + beforeEach(() => { + // A cookie surviving between cases would let a test pass on the previous + // test's write. + document.cookie = "PARAGLIDE_LOCALE=; path=/; max-age=0"; + }); + + it("writes the cookie and persists the choice", async () => { + const submitted = renderSwitcher("en"); + fireEvent.click(await screen.findByRole("radio", { name: /español/i })); + + expect(document.cookie).toContain("PARAGLIDE_LOCALE=es-419"); + // Persisted as the tag the settings { + it("submits the chosen language with the booking", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("radio", { name: /español/i })); + expect(onChange).toHaveBeenCalledWith("es-419"); + }); + + it("defaults to nothing selected, so a default is never mistaken for a choice", () => { + render(); + expect(screen.queryByRole("radio", { checked: true })).toBeNull(); + }); + + it("shows the chosen option as checked once the client answers", () => { + render(); + expect(screen.getByRole("radio", { name: /español/i })).toBeChecked(); + expect(screen.getByRole("radio", { name: /english/i })).not.toBeChecked(); + }); + + it("labels each option in its own language", () => { + render(); + // "Español", not "Spanish": someone who cannot read English cannot find an + // option labelled in English, which would defeat the whole control. + expect(screen.getByRole("radio", { name: /español/i })).toBeTruthy(); + expect(screen.queryByRole("radio", { name: /^spanish/i })).toBeNull(); + }); + + it("offers exactly the locales the server will accept", () => { + // Offering a language the server cannot store would collect an answer and + // then drop it; offering fewer would hide one we can already speak. + render(); + const offered = screen.getAllByRole("radio").map((el) => (el as HTMLInputElement).value); + expect(offered).toEqual([...SUPPORTED_CONTACT_LOCALES]); + }); +}); diff --git a/app/components/booking/LanguageChoice.tsx b/app/components/booking/LanguageChoice.tsx new file mode 100644 index 000000000..f5233e33a --- /dev/null +++ b/app/components/booking/LanguageChoice.tsx @@ -0,0 +1,48 @@ +/** + * "Which language would you like us to use?" — asked of the person booking, + * on every public booking surface. + * + * NOTHING IS SELECTED BY DEFAULT, and that is the whole design. A + * pre-selected English would turn every booking into a stated preference and + * leave no way to tell who actually asked for one; the absence of a value is + * the thing that makes a present value evidence. So `null` is a real state + * here, not an empty one. + * + * The option labels come from the same table the Workspace and Profile + * pickers use (`app/lib/locales.ts`), and the offered values from the same + * list the server accepts, so there is one vocabulary for one choice. + */ +import { RadioGroup } from "@core/shared-ui"; +import { SUPPORTED_CONTACT_LOCALES } from "../../../server/lib/i18n/contact-locale"; +import { localeLabel } from "~/lib/locales"; +import { m } from "~/paraglide/messages"; + +export function LanguageChoice({ + value, + onChange, + options = SUPPORTED_CONTACT_LOCALES, + name = "locale", + legendClassName = "block text-[10px] font-bold uppercase tracking-[0.2em] text-ih-fg-3 mb-1.5", +}: { + /** The chosen tag, or `null` for "the client has not said". */ + value: string | null; + onChange: (value: string) => void; + options?: readonly string[]; + /** Radio group name — distinct per surface when two forms share a document. */ + name?: string; + /** Defaults to the booking wizard's field-label idiom; the embed is tighter. */ + legendClassName?: string; +}) { + return ( + ({ value: tag, label: localeLabel(tag) }))} + /> + ); +} diff --git a/app/components/booking/useBookingFormState.ts b/app/components/booking/useBookingFormState.ts index 8f545d1f1..cb3be2e59 100644 --- a/app/components/booking/useBookingFormState.ts +++ b/app/components/booking/useBookingFormState.ts @@ -28,6 +28,10 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug const [clientEmail, setClientEmail] = useState(""); // Track L (D6, path A) — unchecked-by-default SMS opt-in (TCPA consent). const [smsOptin, setSmsOptin] = useState(false); + // The language the client asked to be addressed in. Starts null and is only + // ever set by them clicking an option: a default here would make every + // booking look like a stated preference. + const [locale, setLocale] = useState(null); const [chosenInspectorId, setChosenInspectorId] = useState(preselected?.id ?? null); const [submitting, setSubmitting] = useState(false); const [message, setMessage] = useState<{ text: string; ok: boolean } | null>(null); @@ -163,6 +167,9 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug clientName, clientEmail, ...(smsOptin ? { smsOptin: true } : {}), + // Omitted entirely when unanswered — the server stores NULL, which + // is not the same as storing 'en'. + ...(locale ? { locale } : {}), ...(turnstileToken ? { turnstileToken } : {}), ...(agentRefSlug ? { agentRefSlug } : {}), }), @@ -192,6 +199,7 @@ export function useBookingFormState({ profile, preselected, tenant, agentRefSlug clientName, setClientName, clientEmail, setClientEmail, smsOptin, setSmsOptin, + locale, setLocale, chosenInspectorId, setChosenInspectorId, submitting: submitting || agentFetcher.state === "submitting", message, diff --git a/app/components/calendar/CalendarEventModal.tsx b/app/components/calendar/CalendarEventModal.tsx index 56e768060..1ccaa340d 100644 --- a/app/components/calendar/CalendarEventModal.tsx +++ b/app/components/calendar/CalendarEventModal.tsx @@ -1,8 +1,30 @@ -import { useNavigate } from "react-router"; +import { Link } from "react-router"; import { Modal } from "@core/shared-ui"; -import type { CalendarEvent } from "~/components/calendar/calendar-helpers"; -import { formatDateTime } from "~/lib/format"; +import { calendarItemHref, type CalendarEvent } from "~/components/calendar/calendar-helpers"; +import { formatDate, formatDateTime } from "~/lib/format"; import { m } from "~/paraglide/messages"; +import { EVENT_STATUS } from "~/lib/status"; + +/** + * A status word the viewer can read. + * + * The modal used to print the raw column value with its underscores swapped for + * spaces, so a Spanish UI showed "results received". These come from the + * message catalogue, which follows the VIEWER'S LANGUAGE — deliberately not + * `useDisplayLocale`, which resolves the tenant's locale SETTING and is the + * reason the calendar chrome above still says "August 2026" under a Spanish UI. + * Language follows the viewer; only date SHAPE follows the tenant. + */ +function statusLabel(status: string): string { + if (status === EVENT_STATUS.SCHEDULED) return m.label_status_scheduled(); + if (status === EVENT_STATUS.COMPLETED) return m.label_status_completed(); + if (status === EVENT_STATUS.CANCELLED) return m.label_status_cancelled(); + if (status === EVENT_STATUS.RESULTS_RECEIVED) return m.calendar_event_status_results_received(); + // Inspection lifecycle values (draft/in_progress/delivered/…) already have + // their own labels elsewhere; until this modal is taught them, the legacy + // rendering is better than a blank. + return status.replace(/_/g, " "); +} interface CalendarEventModalProps { event: CalendarEvent; @@ -13,7 +35,17 @@ interface CalendarEventModalProps { } export function CalendarEventModal({ event, open, displayTz, locale, onClose }: CalendarEventModalProps) { - const navigate = useNavigate(); + // ONE function decides the destination, and it is allowed to answer "nowhere" + // — a company holiday used to render an "Open inspection" button pointing at + // `/inspections/holiday:2026-08-04`. See `calendarItemHref`. + const href = calendarItemHref(event); + // An ALL-DAY item is a civil day, not an instant. Converting one through the + // viewer's zone is the calendar off-by-one in its purest form: a holiday + // stored as `2026-08-27` was rendered as "Aug 26, 2026, 8:00 PM EDT" — the + // wrong DAY, with a time nobody ever set. `timeZone: 'UTC'` here is + // deliberate and is not a display choice: `formatDate` anchors a civil string + // at UTC midnight, so formatting it back in UTC returns the day as written. + const allDay = event.extendedProps?.allDay === true; return ( {m.common_close()} - {event.id && ( - + )} } @@ -49,14 +80,28 @@ export function CalendarEventModal({ event, open, displayTz, locale, onClose }:

{m.calendar_event_date_label()}{" "} - {event.start - ? formatDateTime(event.start, { locale, timeZone: displayTz }) - : m.calendar_event_na()} + {!event.start + ? m.calendar_event_na() + : allDay + ? formatDate(event.civilDate || event.start, { locale, timeZone: "UTC" }) + : formatDateTime(event.start, { locale, timeZone: displayTz })}

+ {/* The wall clock the SERVER already resolved in the viewer's effective + zone. Never re-derived from `start` here — that is the calendar + off-by-one, and a visit computed as "48 hours later" is exactly the + item whose hour moves across a DST boundary. */} + {event.startTime && ( +

+ {m.calendar_event_time_label()}{" "} + {event.endTime + ? m.calendar_event_time_range({ start: event.startTime, end: event.endTime }) + : event.startTime} +

+ )} {event.status && (

{m.calendar_event_status_label()}{" "} - {event.status.replace(/_/g, " ")} + {statusLabel(event.status)}

)}
diff --git a/app/components/calendar/CalendarScopeToolbar.test.tsx b/app/components/calendar/CalendarScopeToolbar.test.tsx index 7356b5ef8..55e93133d 100644 --- a/app/components/calendar/CalendarScopeToolbar.test.tsx +++ b/app/components/calendar/CalendarScopeToolbar.test.tsx @@ -1,51 +1,66 @@ // @vitest-environment happy-dom import { describe, expect, it, vi } from "vitest"; -import { render } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; import { CalendarScopeToolbar } from "./CalendarScopeToolbar"; import { defaultCalendarScope } from "./calendar-helpers"; +/** + * The toolbar now reads the viewer's RESOLVED capabilities (for the Dispatch + * cross-link), so it needs a data router with an auth-layout loader around it — + * rendered bare it throws before any assertion runs. The router hydrates + * asynchronously, which is why every assertion here waits. + */ +function renderToolbar( + ui: React.ReactElement, + capabilities: Record | null = null, +) { + const Stub = createRoutesStub([ + { + path: "/", + id: "routes/auth-layout", + loader: () => (capabilities ? { context: { user: { capabilities } } } : { context: null }), + Component: () => ui, + }, + ]); + return render(); +} + +const BASE = { + members: [], + selectedUserIds: [], + onScopeChange: vi.fn(), + onToggleMember: vi.fn(), + locale: "en-US", +}; + describe("CalendarScopeToolbar", () => { - it("defaults Team for owner", () => { + it("defaults Team for owner", async () => { const scope = defaultCalendarScope("owner"); - const { getByRole } = render( - , - ); + renderToolbar(); expect(scope).toBe("team"); - expect(getByRole("button", { name: "Team" }).getAttribute("aria-pressed")).toBe("true"); - expect(getByRole("button", { name: "My" }).getAttribute("aria-pressed")).toBe("false"); + await waitFor(() => + expect(screen.getByRole("button", { name: "Team" }).getAttribute("aria-pressed")).toBe("true"), + ); + expect(screen.getByRole("button", { name: "My" }).getAttribute("aria-pressed")).toBe("false"); }); - it("defaults My for inspector and hides Team", () => { + it("defaults My for inspector and hides Team", async () => { const scope = defaultCalendarScope("inspector"); - const { getByRole, queryByRole } = render( - , - ); + renderToolbar(); expect(scope).toBe("my"); - expect(getByRole("button", { name: "My" }).getAttribute("aria-pressed")).toBe("true"); - expect(queryByRole("button", { name: "Team" })).toBeNull(); + await waitFor(() => + expect(screen.getByRole("button", { name: "My" }).getAttribute("aria-pressed")).toBe("true"), + ); + expect(screen.queryByRole("button", { name: "Team" })).toBeNull(); }); - it("shows inspector chips in Team mode for managers", () => { - const { getByRole } = render( + it("shows inspector chips in Team mode for managers", async () => { + renderToolbar( { { id: "u2", name: "Sam", email: "sam@example.com", role: "inspector" }, ]} selectedUserIds={["u1"]} - onScopeChange={vi.fn()} - onToggleMember={vi.fn()} - locale="en-US" />, ); - expect(getByRole("button", { name: "Alex" }).getAttribute("aria-pressed")).toBe("true"); - expect(getByRole("button", { name: "Sam" }).getAttribute("aria-pressed")).toBe("false"); + await waitFor(() => + expect(screen.getByRole("button", { name: "Alex" }).getAttribute("aria-pressed")).toBe("true"), + ); + expect(screen.getByRole("button", { name: "Sam" }).getAttribute("aria-pressed")).toBe("false"); + }); + + it("offers Dispatch only to a viewer who holds scheduleOthers", async () => { + // A manager whose override was revoked: the role tier says yes, the + // capability says no, and /calendar/dispatch would redirect them back. + renderToolbar( + , + { scheduleOthers: false }, + ); + await waitFor(() => expect(screen.getByRole("button", { name: "Team" })).toBeTruthy()); + expect(screen.queryByTestId("calendar-open-dispatch")).toBeNull(); + + renderToolbar( + , + { scheduleOthers: true }, + ); + expect(await screen.findByTestId("calendar-open-dispatch")).toBeTruthy(); + }); + + it("keeps the cross-link out of My mode", async () => { + renderToolbar( + , + { scheduleOthers: true }, + ); + await waitFor(() => expect(screen.getByRole("button", { name: "My" })).toBeTruthy()); + expect(screen.queryByTestId("calendar-open-dispatch")).toBeNull(); }); - it("shows sync freshness beside each Team chip", () => { + it("shows sync freshness beside each Team chip", async () => { const now = Date.UTC(2026, 7, 3, 12, 0, 0); - const { container } = render( + const { container } = renderToolbar( { }, ]} selectedUserIds={["u1"]} - onScopeChange={vi.fn()} - onToggleMember={vi.fn()} - locale="en-US" now={now} />, ); - const states = [...container.querySelectorAll("[data-sync-state]")] - .map((el) => el.getAttribute("data-sync-state")); - expect(states).toEqual(["connected", "stale", "not-connected"]); + await waitFor(() => { + const states = [...container.querySelectorAll("[data-sync-state]")] + .map((el) => el.getAttribute("data-sync-state")); + expect(states).toEqual(["connected", "stale", "not-connected"]); + }); }); }); diff --git a/app/components/calendar/CalendarScopeToolbar.tsx b/app/components/calendar/CalendarScopeToolbar.tsx index a05e43bdd..78d86920d 100644 --- a/app/components/calendar/CalendarScopeToolbar.tsx +++ b/app/components/calendar/CalendarScopeToolbar.tsx @@ -1,4 +1,7 @@ +import { Link } from "react-router"; +import { Button } from "@core/shared-ui"; import { isAdminRole } from "~/lib/access"; +import { useCapability } from "~/hooks/useSessionContext"; import type { CalendarScope } from "~/components/calendar/calendar-helpers"; import type { CalendarMember } from "~/components/calendar/BlockTimeDrawer"; import { InspectorSyncBadge } from "~/components/calendar/InspectorSyncBadge"; @@ -31,9 +34,20 @@ export function CalendarScopeToolbar({ now, }: CalendarScopeToolbarProps) { const canManageTeam = isAdminRole(role); + // The cross-link is gated on the CAPABILITY, not on canManageTeam: + // /calendar/dispatch is guarded by `scheduleOthers`, so a role-tier button + // would offer a manager whose override was revoked a page that redirects + // straight back here. The existing canManageTeam uses stay as they are — + // reconciling /api/calendar/items with the capability is a separate gap. + const canDispatch = useCapability("scheduleOthers"); return (
+ {canDispatch && scope === "team" && ( + + + + )}
- {/* Snapshot content (scrollable) */} -
+ {/* Snapshot content (scrollable) — tenant data, and the only thing + being signed. */} +
+ {/* Platform disclosure — a sibling of the snapshot, outside its scroll + region and never composed into it. */} + + {isDone ? (
diff --git a/app/components/collab/VersionHistoryPanel.tsx b/app/components/collab/VersionHistoryPanel.tsx index 1842b3463..e435026a1 100644 --- a/app/components/collab/VersionHistoryPanel.tsx +++ b/app/components/collab/VersionHistoryPanel.tsx @@ -5,6 +5,8 @@ import { applyItemPatch } from "../../../server/lib/collab/results-doc"; import type { ResultsProjection } from "../../../server/lib/collab/results-doc.types"; import { diffProjections, type FindingDiff, type ScalarField } from "~/lib/collab/snapshot-diff"; import { VersionCompare } from "~/components/collab/VersionCompare"; +import { useDisplayTimeZone, useInspectionDateTimeFormat } from "~/hooks/useSessionContext"; +import { formatShapedDate, type InspectionDateTimeFormat } from "~/lib/format-date"; import { m } from "~/paraglide/messages"; /** @@ -78,9 +80,21 @@ function reasonLabel(reason: SnapshotReason | undefined, byUserId: string | null /** * Tiny dependency-free relative-time formatter ("just now", "2 minutes ago", - * "3 hours ago", "5 days ago"). Falls back to a locale date for older entries. + * "3 hours ago", "5 days ago"). Falls back to an absolute date past a week. + * + * `timeZone` and `fmt` are REQUIRED for the fallback branch (#270). The old + * `new Date(atMs).toLocaleDateString()` read the BROWSER's zone and locale, so + * two collaborators on the same document could see two different dates against + * the same snapshot — and this list is the one place they point at a version + * and say "restore that one". Both resolve from the TENANT + * (`useInspectionDateTimeFormat`) for exactly that reason. */ -export function formatRelativeTime(atMs: number, now: number = Date.now()): string { +export function formatRelativeTime( + atMs: number, + now: number, + timeZone: string, + fmt: InspectionDateTimeFormat, +): string { const diffMs = now - atMs; if (!Number.isFinite(diffMs) || diffMs < 0) return m.editor_collab_just_now(); const sec = Math.floor(diffMs / 1000); @@ -91,7 +105,7 @@ export function formatRelativeTime(atMs: number, now: number = Date.now()): stri if (hr < 24) return m.editor_collab_hours_ago({ hr, s: hr === 1 ? "" : "s" }); const day = Math.floor(hr / 24); if (day < 7) return m.editor_collab_days_ago({ day, s: day === 1 ? "" : "s" }); - return new Date(atMs).toLocaleDateString(); + return formatShapedDate(atMs, timeZone, fmt); } /** Narrow an `unknown` JSON payload to the snapshot list shape. */ @@ -161,6 +175,11 @@ export function VersionHistoryPanel({ const canCompare = !!currentResults; + // Tenant-anchored (#270): every collaborator on this document must read the + // same date off this list. + const timeZone = useDisplayTimeZone(); + const fmt = useInspectionDateTimeFormat(); + const base = `/api/inspections/${inspectionId}/collab`; // #181 PR-H — open Compare for a row: fetch that snapshot's projection (the @@ -370,7 +389,7 @@ export function VersionHistoryPanel({ >
- {formatRelativeTime(snap.atMs)} + {formatRelativeTime(snap.atMs, Date.now(), timeZone, fmt)}
{reasonLabel(snap.reason, snap.byUserId)} diff --git a/app/components/collab/version-compare.test.ts b/app/components/collab/version-compare.test.ts index 839be63ff..50946daac 100644 --- a/app/components/collab/version-compare.test.ts +++ b/app/components/collab/version-compare.test.ts @@ -8,6 +8,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createElement, act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; + +// #270 — the panel reads the TENANT's zone + date shape, and those hooks bottom +// out in `useRouteLoaderData`, which invariants in this router-free harness. +// Kept identical to version-history-panel.test.ts: both suites render the same +// component, so a stub added to one and not the other only looks green. +vi.mock('~/hooks/useSessionContext', () => ({ + useDisplayTimeZone: () => 'UTC', + useInspectionDateTimeFormat: () => ({ locale: 'en-US', dateFormat: 'us', timeFormat: '12h' }), +})); + import { VersionCompare } from '~/components/collab/VersionCompare'; import { VersionHistoryPanel } from '~/components/collab/VersionHistoryPanel'; import type { FindingDiff } from '~/lib/collab/snapshot-diff'; diff --git a/app/components/collab/version-history-panel.test.ts b/app/components/collab/version-history-panel.test.ts index fcaea4479..dce0080cb 100644 --- a/app/components/collab/version-history-panel.test.ts +++ b/app/components/collab/version-history-panel.test.ts @@ -9,8 +9,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createElement, act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; + +// #270 — the panel reads the TENANT's zone + date shape, and those hooks bottom +// out in `useRouteLoaderData`, which invariants in this router-free harness. +vi.mock('~/hooks/useSessionContext', () => ({ + useDisplayTimeZone: () => 'UTC', + useInspectionDateTimeFormat: () => ({ locale: 'en-US', dateFormat: 'us', timeFormat: '12h' }), +})); + import { VersionHistoryPanel, formatRelativeTime } from '~/components/collab/VersionHistoryPanel'; +const UTC_US = { locale: 'en-US', dateFormat: 'us' as const, timeFormat: '12h' as const }; + let container: HTMLDivElement | null = null; let root: Root | null = null; @@ -82,10 +92,23 @@ const SNAPSHOTS = [ describe('formatRelativeTime', () => { it('formats recent/minute/hour/day buckets', () => { const now = 10_000_000_000; - expect(formatRelativeTime(now - 1_000, now)).toBe('just now'); - expect(formatRelativeTime(now - 120_000, now)).toBe('2 minutes ago'); - expect(formatRelativeTime(now - 3_600_000, now)).toBe('1 hour ago'); - expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2 days ago'); + expect(formatRelativeTime(now - 1_000, now, 'UTC', UTC_US)).toBe('just now'); + expect(formatRelativeTime(now - 120_000, now, 'UTC', UTC_US)).toBe('2 minutes ago'); + expect(formatRelativeTime(now - 3_600_000, now, 'UTC', UTC_US)).toBe('1 hour ago'); + expect(formatRelativeTime(now - 2 * 86_400_000, now, 'UTC', UTC_US)).toBe('2 days ago'); + }); + + // #270 — the >7d fallback was `new Date(atMs).toLocaleDateString()`: the + // BROWSER's locale and zone, so two collaborators could read two different + // dates off the same snapshot. Shape and zone now come from the tenant. + it('renders the older-than-a-week fallback in the tenant date shape and zone', () => { + const atMs = Date.UTC(2026, 8, 11, 23, 30); // 2026-09-11T23:30Z + const now = atMs + 30 * 86_400_000; + expect(formatRelativeTime(atMs, now, 'UTC', UTC_US)).toBe('Sep 11, 2026'); + expect(formatRelativeTime(atMs, now, 'UTC', { ...UTC_US, dateFormat: 'iso' })).toBe('2026-09-11'); + // A zone that rolls the instant back a day proves the zone is honoured too. + expect(formatRelativeTime(atMs, now, 'America/New_York', UTC_US)).toBe('Sep 11, 2026'); + expect(formatRelativeTime(atMs, now, 'Asia/Tokyo', UTC_US)).toBe('Sep 12, 2026'); }); }); diff --git a/app/components/contacts/ContactModal.test.tsx b/app/components/contacts/ContactModal.test.tsx new file mode 100644 index 000000000..0059788a3 --- /dev/null +++ b/app/components/contacts/ContactModal.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment happy-dom +/** + * Staff have to be able to CORRECT a contact's language, which is a stronger + * requirement than being able to set one: the booking form can leave the choice + * unanswered forever, but this form is the only place a wrong answer gets + * undone. So "Not set" is an option here rather than merely the initial state, + * and it is first — a pre-selected English would turn every contact anyone ever + * edits into a stated preference, and a stated preference is the only thing the + * column is evidence of. + */ +import { describe, it, expect } from "vitest"; +import { render, within } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; + +import { ContactModal } from "./ContactModal"; +import type { Contact } from "./contacts-helpers"; + +const BASE: Contact = { + id: "c1", + name: "Tomas Beck", + email: "tomas@example.com", + phone: "", + type: "client", + agency: "", +}; + +function renderModal(contact: Contact | null) { + const Stub = createRoutesStub([ + { + path: "/contacts", + Component: () => {}} contact={contact} />, + }, + ]); + return render(); +} + +describe("ContactModal — preferred language", () => { + it("offers 'not set' first, so a language can be taken back off", async () => { + const { findByLabelText } = renderModal({ ...BASE, locale: "es-419" }); + const select = (await findByLabelText("Preferred language")) as HTMLSelectElement; + + const [first, ...rest] = Array.from(select.options); + expect(first.value).toBe(""); + // Every other option carries a real tag — nothing else is a way out. + expect(rest.map((o) => o.value)).toEqual(["en", "es-419"]); + }); + + it("shows what the contact already asked for", async () => { + const { findByLabelText } = renderModal({ ...BASE, locale: "es-419" }); + const select = (await findByLabelText("Preferred language")) as HTMLSelectElement; + + expect(select.value).toBe("es-419"); + expect(within(select).getByRole("option", { selected: true })).toHaveTextContent( + // Same label the booking form and the profile picker use — one + // vocabulary for one choice. + "Español (Latinoamérica)", + ); + }); + + it("sits on 'not set' for a contact who has never said", async () => { + const { findByLabelText } = renderModal({ ...BASE, locale: null }); + const select = (await findByLabelText("Preferred language")) as HTMLSelectElement; + expect(select.value).toBe(""); + }); + + it("sits on 'not set' for a brand-new contact", async () => { + const { findByLabelText } = renderModal(null); + const select = (await findByLabelText("Preferred language")) as HTMLSelectElement; + expect(select.value).toBe(""); + }); +}); diff --git a/app/components/contacts/ContactModal.tsx b/app/components/contacts/ContactModal.tsx index 52f013a5d..c3186fc3f 100644 --- a/app/components/contacts/ContactModal.tsx +++ b/app/components/contacts/ContactModal.tsx @@ -3,6 +3,8 @@ import { useForm, type SubmissionResult } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod/v4"; import { makeAddContactSchema } from "~/lib/forms/contacts.schema"; import { Modal, Button } from "@core/shared-ui"; +import { SUPPORTED_CONTACT_LOCALES } from "../../../server/lib/i18n/contact-locale"; +import { localeLabel } from "~/lib/locales"; import { m } from "~/paraglide/messages"; import type { Contact } from "./contacts-helpers"; @@ -146,6 +148,31 @@ export function ContactModal({ />
+ {/* The language to address this person in. Staff set it as a + CORRECTION — the client said so on the phone, or picked wrong on + the booking form — so "Not set" has to be reachable again, and is + the first option rather than a pre-selected English: a stored + value is a stated preference, and only a stated preference is + evidence anyone wants another language. + + Same three-state shape and the same option labels as the profile + picker in Settings, from one table (`app/lib/locales.ts`), over + the same list of tags the server accepts. */} +
+ + +
+ {form.errors && (
{form.errors[0]} diff --git a/app/components/contacts/contacts-helpers.ts b/app/components/contacts/contacts-helpers.ts index 786380973..d1d2af87e 100644 --- a/app/components/contacts/contacts-helpers.ts +++ b/app/components/contacts/contacts-helpers.ts @@ -27,6 +27,9 @@ export interface Contact { phone: string; type: string; agency: string; + /** BCP-47 tag the contact asked to be addressed in; null/absent means they + * have not said, which is NOT the same as English. */ + locale?: string | null; inspectionCount?: number; referralCount?: number; } diff --git a/app/components/dashboard/DashboardInspectionRow.test.tsx b/app/components/dashboard/DashboardInspectionRow.test.tsx index 00fd34b50..a7f276fd8 100644 --- a/app/components/dashboard/DashboardInspectionRow.test.tsx +++ b/app/components/dashboard/DashboardInspectionRow.test.tsx @@ -22,6 +22,8 @@ vi.mock("~/hooks/useSessionContext", () => ({ useDisplayLocale: () => "en-US", useDisplayCurrency: () => "USD", useDisplayTimeZone: () => "UTC", + // #270 — the row renders an inspection date, whose SHAPE is the tenant's. + useTenantFormatPrefs: () => ({ dateFormat: "us", timeFormat: "12h" }), })); const INSPECTION = { diff --git a/app/components/dashboard/DashboardInspectionRow.tsx b/app/components/dashboard/DashboardInspectionRow.tsx index a072fa296..7c5cc948f 100644 --- a/app/components/dashboard/DashboardInspectionRow.tsx +++ b/app/components/dashboard/DashboardInspectionRow.tsx @@ -6,7 +6,7 @@ import { REPORT_STATE_TONE, type Inspection } from "~/lib/dashboard-schema"; import { Pill, Icon } from "@core/shared-ui"; import { m } from "~/paraglide/messages"; import { formatDollars } from "~/lib/money"; -import { useDisplayLocale, useDisplayCurrency } from "~/hooks/useSessionContext"; +import { useDisplayLocale, useDisplayCurrency, useTenantFormatPrefs } from "~/hooks/useSessionContext"; interface DashboardInspectionRowProps { insp: Inspection; @@ -36,6 +36,7 @@ export function DashboardInspectionRow({ }: DashboardInspectionRowProps) { const locale = useDisplayLocale(); const currency = useDisplayCurrency(); + const shape = useTenantFormatPrefs(); const isSelected = selectedIds.has(insp.id); const showReportLink = reportView && tenantSlug && isReportPublished(insp.reportStatus); @@ -65,7 +66,7 @@ export function DashboardInspectionRow({ )} {isColumnVisible("date") && insp.date && ( - · {formatInspectionDateTime(insp.date, undefined, timeZone)} + · {formatInspectionDateTime(insp.date, undefined, timeZone, { locale, ...shape })} )} {isColumnVisible("agent") && insp.agentName && ( diff --git a/app/components/dispatch/ConflictModal.tsx b/app/components/dispatch/ConflictModal.tsx new file mode 100644 index 000000000..68e3e7190 --- /dev/null +++ b/app/components/dispatch/ConflictModal.tsx @@ -0,0 +1,48 @@ +import { Button, Modal } from "@core/shared-ui"; +import { m } from "~/paraglide/messages"; +import type { ScheduleConflict } from "./dispatch-helpers"; + +/** + * What a refused drop is allowed to say. + * + * The tenant's `booking_conflict_policy` is `block`, so the server already + * declined the write — this window reports a decision, it does not ask for + * one. There is deliberately no "do it anyway": an override here would make + * the setting a suggestion, and the same drag would then mean different things + * depending on which surface performed it. + * + * It names the colliding jobs because "that slot is taken" without saying BY + * WHAT sends the dispatcher hunting through the board they were already + * looking at. + */ +export function ConflictModal({ + open, + conflicts, + onClose, +}: { + open: boolean; + conflicts: ScheduleConflict[]; + onClose: () => void; +}) { + return ( + {m.dispatch_conflict_close()}} + > +

{m.dispatch_conflict_body()}

+
    + {conflicts.map((conflict) => ( +
  • + {conflict.propertyAddress} + {conflict.date} +
  • + ))} +
+
+ ); +} diff --git a/app/components/dispatch/DispatchBoard.test.tsx b/app/components/dispatch/DispatchBoard.test.tsx new file mode 100644 index 000000000..bcb85d4d7 --- /dev/null +++ b/app/components/dispatch/DispatchBoard.test.tsx @@ -0,0 +1,310 @@ +// @vitest-environment happy-dom +/** + * A dispatch board is a claim about WHERE work is: which person owns it, and + * what hour it sits at. Both halves are silent when wrong — a card in the wrong + * column still looks like a card, and a job at 06:00 that the axis cannot show + * simply is not there. + * + * So the assertions here are about placement, not about pixels being pretty: + * a card lands under its owner and nowhere else, an unowned inspection lands in + * the lane, a company holiday belongs to the whole board rather than to a + * person, and an out-of-axis job is clamped into view rather than dropped. + */ +import { describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; + +import { DispatchBoard } from "./DispatchBoard"; +import { + BOARD_START_HOUR, + HOUR_HEIGHT_PX, + bucketColumn, + cardGeometry, + closureItems, + isDraggableItem, + minuteFromOffsetY, + shiftCivilDate, + snapMinute, + type DispatchItem, + type DispatchPayload, +} from "./dispatch-helpers"; + +function item(over: Partial & { id: string }): DispatchItem { + return { + kind: "inspection", + title: "Job", + start: "2027-03-15", + end: "2027-03-15", + civilDate: "2027-03-15", + allDay: false, + ...over, + }; +} + +const ROSTER = [ + { id: "u-ada", name: "Ada", email: "ada@example.com", role: "inspector" }, + { id: "u-bo", name: null, email: "bo@example.com", role: "manager" }, +]; + +const DAY_START_MS = Date.UTC(2027, 2, 15, 4, 0, 0); + +const BOARD: DispatchPayload = { + date: "2027-03-15", + conflictPolicy: "block", + slotIntervalMin: 30, + dayStartMs: DAY_START_MS, + inspectors: ROSTER, + items: [ + item({ id: "i-1", title: "Maple St", startTime: "09:00", endTime: "11:00", inspectionId: "insp-1", userId: "u-ada" }), + item({ id: "i-2", title: "Oak Ave", startTime: "13:00", endTime: "14:00", inspectionId: "insp-2", userId: "u-bo" }), + item({ id: "i-3", title: "Pine Rd", startTime: "10:00", inspectionId: "insp-3" }), + item({ id: "h-1", kind: "company_holiday", title: "Founders Day", allDay: true }), + ], + unassigned: [ + item({ id: "i-3", title: "Pine Rd", startTime: "10:00", inspectionId: "insp-3" }), + ], +}; + +function renderBoard( + board: DispatchPayload = BOARD, + action?: (args: { request: Request }) => unknown, +) { + const Stub = createRoutesStub([ + { + path: "/", + Component: () => , + ...(action ? { action } : {}), + }, + ]); + return render(); +} + +/** + * happy-dom reports a zero-origin rect, so clientY IS the axis offset here. + * + * The drop is dispatched as a real MouseEvent named "drop" rather than through + * `fireEvent.drop`: happy-dom's DragEvent does not carry pointer coordinates, + * and a drop with no clientY is exactly the case these tests exist to pin down. + */ +function dragCardTo(cardText: string, dropzone: Element, clientY: number) { + const card = screen.getByText(cardText).closest("[data-item-id]") as HTMLElement; + // A real dataTransfer, not a spy: the drop handler READS back what dragstart + // wrote, which is the whole point of carrying the id through the gesture. + const store: Record = {}; + const dataTransfer = { + setData: (key: string, value: string) => { store[key] = value; }, + getData: (key: string) => store[key] ?? "", + effectAllowed: "", + }; + fireEvent.dragStart(card, { dataTransfer }); + for (const type of ["dragover", "drop"]) { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientY }); + Object.defineProperty(event, "dataTransfer", { value: dataTransfer }); + fireEvent(dropzone, event); + } +} + +describe("DispatchBoard", () => { + it("puts each card in its owner's column and nowhere else", () => { + renderBoard(); + const columns = screen.getAllByTestId("dispatch-column"); + expect(columns).toHaveLength(2); + + expect(columns[0].getAttribute("data-inspector-id")).toBe("u-ada"); + expect(within(columns[0]).getByText("Maple St")).toBeTruthy(); + expect(within(columns[0]).queryByText("Oak Ave")).toBeNull(); + + expect(columns[1].getAttribute("data-inspector-id")).toBe("u-bo"); + expect(within(columns[1]).getByText("Oak Ave")).toBeTruthy(); + }); + + it("falls back to the email when an inspector has no name", () => { + renderBoard(); + expect(screen.getByText("bo@example.com")).toBeTruthy(); + }); + + it("keeps an unowned inspection in the lane and out of every column", () => { + renderBoard(); + const lane = screen.getByTestId("dispatch-unassigned-lane"); + expect(within(lane).getByText("Pine Rd")).toBeTruthy(); + for (const column of screen.getAllByTestId("dispatch-column")) { + expect(within(column).queryByText("Pine Rd")).toBeNull(); + } + }); + + it("shows a company closure once for the whole board, not per column", () => { + renderBoard(); + expect(screen.getAllByText(/Founders Day/)).toHaveLength(1); + for (const column of screen.getAllByTestId("dispatch-column")) { + expect(within(column).queryByText(/Founders Day/)).toBeNull(); + } + }); + + it("renders an empty roster as an empty state rather than a bare axis", () => { + renderBoard({ ...BOARD, inspectors: [], items: [], unassigned: [] }); + expect(screen.queryAllByTestId("dispatch-column")).toHaveLength(0); + expect(screen.getByText("No inspectors yet")).toBeTruthy(); + }); +}); + +describe("DispatchBoard drag-drop", () => { + it("sends the dropped column AND the snapped instant in one write", async () => { + const posted: Record[] = []; + renderBoard(BOARD, async ({ request }) => { + const form = await request.formData(); + posted.push(Object.fromEntries(form) as Record); + return { ok: true, conflicts: [] }; + }); + + const ada = screen.getAllByTestId("dispatch-column")[0]; + // 112px below the axis top = 09:00 on a 56px hour starting at 07:00. + dragCardTo("Pine Rd", ada.querySelector("[data-dispatch-dropzone]")!, 112); + + await waitFor(() => expect(posted).toHaveLength(1)); + expect(posted[0]).toMatchObject({ + intent: "reschedule", + inspectionId: "insp-3", + leadInspectorId: "u-ada", + scheduledStartMs: String(DAY_START_MS + 9 * 60 * 60_000), + }); + }); + + it("snaps a between-slots drop onto the tenant's booking lattice", async () => { + const posted: Record[] = []; + renderBoard(BOARD, async ({ request }) => { + const form = await request.formData(); + posted.push(Object.fromEntries(form) as Record); + return { ok: true, conflicts: [] }; + }); + + const ada = screen.getAllByTestId("dispatch-column")[0]; + // 130px ≈ 09:19 — with a 30-minute interval the only honest answer is 09:30. + dragCardTo("Pine Rd", ada.querySelector("[data-dispatch-dropzone]")!, 130); + + await waitFor(() => expect(posted).toHaveLength(1)); + expect(posted[0].scheduledStartMs).toBe(String(DAY_START_MS + (9 * 60 + 30) * 60_000)); + }); + + it("unassigns with the time intact when a card is dropped on the lane", async () => { + const posted: Record[] = []; + renderBoard(BOARD, async ({ request }) => { + const form = await request.formData(); + posted.push(Object.fromEntries(form) as Record); + return { ok: true, conflicts: [] }; + }); + + dragCardTo("Maple St", screen.getByTestId("dispatch-unassigned-lane"), 0); + + await waitFor(() => expect(posted).toHaveLength(1)); + expect(posted[0].leadInspectorId).toBe(""); + expect(posted[0].scheduledStartMs).toBe(String(DAY_START_MS + 9 * 60 * 60_000)); + }); + + it("opens the conflict modal on a blocked drop instead of claiming success", async () => { + renderBoard(BOARD, async () => ({ + ok: false, + code: "SCHEDULE_CONFLICT", + message: "blocked", + conflicts: [{ inspectionId: "insp-9", propertyAddress: "77 Cedar Ln", date: "2027-03-15", inspectorId: "u-ada" }], + })); + + const ada = screen.getAllByTestId("dispatch-column")[0]; + dragCardTo("Pine Rd", ada.querySelector("[data-dispatch-dropzone]")!, 112); + + await waitFor(() => expect(screen.getByText("77 Cedar Ln")).toBeTruthy()); + expect(screen.getByText("That slot is already taken")).toBeTruthy(); + }); + + it("reads the dropped card from dataTransfer, not from a rendered state flush", async () => { + // Found in Chrome: `dragstart` sets React state, and a drop that lands + // before the re-render saw `null` and silently did nothing. Firing the drop + // with NO preceding dragstart is that race, made deterministic. + const posted: Record[] = []; + renderBoard(BOARD, async ({ request }) => { + const form = await request.formData(); + posted.push(Object.fromEntries(form) as Record); + return { ok: true, conflicts: [] }; + }); + + const ada = screen.getAllByTestId("dispatch-column")[0]; + const zone = ada.querySelector("[data-dispatch-dropzone]")!; + const event = new MouseEvent("drop", { bubbles: true, cancelable: true, clientY: 112 }); + Object.defineProperty(event, "dataTransfer", { value: { getData: () => "i-3", setData: vi.fn() } }); + fireEvent(zone, event); + + await waitFor(() => expect(posted).toHaveLength(1)); + expect(posted[0].inspectionId).toBe("insp-3"); + expect(posted[0].leadInspectorId).toBe("u-ada"); + }); + + it("does not offer a company closure as a drag source", () => { + renderBoard(); + const closure = screen.getByText(/Founders Day/); + expect(closure.closest("[draggable=true]")).toBeNull(); + expect(isDraggableItem(item({ id: "h", kind: "company_holiday", allDay: true }))).toBe(false); + expect(isDraggableItem(item({ id: "b", kind: "calendar_block", startTime: "09:00", userId: "u-ada" }))).toBe(false); + }); +}); + +describe("dispatch-helpers", () => { + it("snaps to the tenant interval, never to a prettier number", () => { + expect(snapMinute(9 * 60 + 19, 30)).toBe(9 * 60 + 30); + expect(snapMinute(9 * 60 + 14, 30)).toBe(9 * 60); + expect(snapMinute(9 * 60 + 7, 15)).toBe(9 * 60); + expect(snapMinute(9 * 60 + 8, 15)).toBe(9 * 60 + 15); + // A zero/garbage interval must not divide by zero into NaN minutes. + expect(snapMinute(9 * 60 + 19, 0)).toBe(9 * 60 + 30); + }); + + it("clamps a drop past either end of the axis back onto it", () => { + expect(minuteFromOffsetY(-500, 30)).toBe(BOARD_START_HOUR * 60); + expect(minuteFromOffsetY(99_999, 30)).toBe(19 * 60); + }); + + + it("places a card at the pixel its start hour implies", () => { + const geometry = cardGeometry(item({ id: "x", startTime: "09:00", endTime: "11:00" })); + expect(geometry).not.toBeNull(); + expect(geometry?.topPx).toBe((9 - BOARD_START_HOUR) * HOUR_HEIGHT_PX); + expect(geometry?.heightPx).toBe(2 * HOUR_HEIGHT_PX); + expect(geometry?.clippedStart).toBe(false); + }); + + it("gives an end-less card a default span instead of a zero-height sliver", () => { + const geometry = cardGeometry(item({ id: "x", startTime: "09:00" })); + expect(geometry?.heightPx).toBe(HOUR_HEIGHT_PX); + }); + + it("clamps a pre-dawn job into view and says it is clipped", () => { + const geometry = cardGeometry(item({ id: "x", startTime: "05:00", endTime: "06:00" })); + expect(geometry).not.toBeNull(); + expect(geometry?.topPx).toBe(0); + expect(geometry?.clippedStart).toBe(true); + }); + + it("has no geometry for an all-day item, so it cannot land at a random hour", () => { + expect(cardGeometry(item({ id: "x", allDay: true, startTime: "09:00" }))).toBeNull(); + expect(cardGeometry(item({ id: "y" }))).toBeNull(); + }); + + it("keeps a company holiday out of a person's column even when it has a userId", () => { + const items = [item({ id: "h", kind: "company_holiday", title: "Closed", allDay: true, userId: "u-ada" })]; + expect(bucketColumn(items, "u-ada").untimed).toHaveLength(0); + expect(closureItems(items)).toHaveLength(1); + }); + + it("sorts a column by start time, not by feed order", () => { + const items = [ + item({ id: "late", startTime: "15:00", userId: "u-ada" }), + item({ id: "early", startTime: "08:00", userId: "u-ada" }), + ]; + expect(bucketColumn(items, "u-ada").timed.map((i) => i.id)).toEqual(["early", "late"]); + }); + + it("steps civil dates across a month boundary and a DST spring-forward without drifting", () => { + expect(shiftCivilDate("2027-02-28", 1)).toBe("2027-03-01"); + expect(shiftCivilDate("2027-01-01", -1)).toBe("2026-12-31"); + // US DST begins 2027-03-14; a day step must still be exactly one day. + expect(shiftCivilDate("2027-03-14", 1)).toBe("2027-03-15"); + }); +}); diff --git a/app/components/dispatch/DispatchBoard.tsx b/app/components/dispatch/DispatchBoard.tsx new file mode 100644 index 000000000..24fe60893 --- /dev/null +++ b/app/components/dispatch/DispatchBoard.tsx @@ -0,0 +1,219 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useFetcher } from "react-router"; +import { EmptyState } from "@core/shared-ui"; +import { m } from "~/paraglide/messages"; +import { pushToast } from "~/hooks/useToast"; +import { UnassignedLane } from "./UnassignedLane"; +import { ConflictModal } from "./ConflictModal"; +import { InspectorColumn, TimeGutter } from "./DispatchColumn"; +import { + axisHeightPx, + boardHours, + closureItems, + currentStartMs, + isDraggableItem, + minuteFromOffsetY, + minuteToEpochMs, + type DispatchItem, + type DispatchPayload, + type RescheduleResult, + type ScheduleConflict, +} from "./dispatch-helpers"; + +/** + * The dispatch board: one column per schedulable person, one shared time axis, + * and the unassigned lane pinned to the left. + * + * Dragging uses the platform's own HTML5 drag-and-drop, the same mechanism the + * calendar's day/week/month views already use. The plan reached for sortablejs + * because it is already a dependency — but sortablejs REORDERS DOM CHILDREN, + * and every card here is absolutely positioned on a time axis. Its drop model + * ("between these two siblings") cannot express this board's only question, + * "which pixel did you let go at", and making it answer that means mutating the + * DOM and then undoing the mutation so React can re-render from server state. + * HTML5 DnD answers it directly with `clientY`, adds no dependency either, and + * leaves React the single source of truth. + * + * A drop is one write: `PATCH /api/inspections/:id/schedule` through the route + * action, carrying both the new instant and the new lead. Time and ownership + * move together because a dispatcher's gesture moves them together — two calls + * would leave a window where the board shows a job at a time nobody owns. + */ +export function DispatchBoard({ board }: { board: DispatchPayload }) { + const fetcher = useFetcher(); + const hours = boardHours(); + const closures = closureItems(board.items); + const axisPx = axisHeightPx(); + + const [draggingId, setDraggingId] = useState(null); + const [hover, setHover] = useState<{ inspectorId: string; minute: number } | null>(null); + const [blocked, setBlocked] = useState(null); + + const byId = useMemo( + () => new Map(board.items.map((item) => [item.id, item])), + [board.items], + ); + + // Report each result once. `fetcher.data` survives across re-renders, so a + // plain effect on it would re-toast on every unrelated state change — the + // board has several (hover, drag id), and a warning that reappears when you + // move the mouse reads as a second failure. + const handled = useRef(null); + useEffect(() => { + const data = fetcher.data; + if (!data || fetcher.state !== "idle" || handled.current === data) return; + handled.current = data; + if (data.ok) { + if (data.conflicts && data.conflicts.length > 0) { + pushToast({ message: m.dispatch_toast_overlap(), variant: "warning", durationMs: 6000 }); + } + return; + } + if (data.code === "SCHEDULE_CONFLICT") { + setBlocked(data.conflicts ?? []); + return; + } + pushToast({ + message: data.message || m.dispatch_toast_failed(), + variant: "error", + durationMs: 6000, + }); + }, [fetcher.data, fetcher.state]); + + /** + * Which card is being dropped, read from the DRAG ITSELF. + * + * `draggingId` state is set in `dragstart`, and a handler closure only sees + * it after React has re-rendered. A real browser leaves many frames between + * the two events so that usually happens — but "usually" is the whole bug: + * a drop that lands before the re-render read `null` and silently did + * nothing. `dataTransfer` carries the id through the gesture with no render + * in between, which is what it is for. State stays the fallback (and drives + * the hover indicator, where a frame of lag is invisible). + */ + function draggedFrom(event: React.DragEvent): DispatchItem | null { + const id = event.dataTransfer?.getData("text/plain") || draggingId; + return id ? byId.get(id) ?? null : null; + } + + function move(item: DispatchItem, startMs: number, leadInspectorId: string) { + if (!item.inspectionId) return; + fetcher.submit( + { + intent: "reschedule", + inspectionId: item.inspectionId, + scheduledStartMs: String(startMs), + leadInspectorId, + }, + { method: "post" }, + ); + } + + function dropOnColumn(inspectorId: string, event: React.DragEvent) { + event.preventDefault(); + const dragged = draggedFrom(event); + setHover(null); + setDraggingId(null); + if (!dragged || !isDraggableItem(dragged)) return; + const rect = event.currentTarget.getBoundingClientRect(); + const minute = minuteFromOffsetY(event.clientY - rect.top, board.slotIntervalMin); + // A drop with no usable pointer position is not a time. Sending it anyway + // would post NaN milliseconds and move the job to the epoch. + if (!Number.isFinite(minute)) return; + move(dragged, minuteToEpochMs(board.dayStartMs, minute), inspectorId); + } + + // Dropping into the lane is an UNASSIGN, not a reschedule: the time the job + // was pencilled in for is exactly what a dispatcher is still holding while + // they look for someone to work it, so the instant is carried over unchanged. + function dropOnLane(event: React.DragEvent) { + event.preventDefault(); + const dragged = draggedFrom(event); + setHover(null); + setDraggingId(null); + if (!dragged || !isDraggableItem(dragged)) return; + const startMs = currentStartMs(dragged, board.dayStartMs); + if (startMs == null) return; + move(dragged, startMs, ""); + } + + const busy = fetcher.state !== "idle"; + + return ( + <> +
+ {closures.length > 0 && ( +
+ {closures.map((closure) => ( + + {m.dispatch_closed_prefix()}: {closure.title} + + ))} +
+ )} + +
+ setDraggingId(null)} + onDropItem={dropOnLane} + /> + + {board.inspectors.length === 0 ? ( +
+ +
+ ) : ( +
+ {/* Dispatch is a desktop-first surface. On a narrow screen the + columns scroll sideways rather than reflow — a stacked board + is a list, and a list cannot show two people's 10:00 at once, + which is the entire reason to open this page. Say so, once, + where the gesture is needed. */} +

+ {m.dispatch_scroll_hint()} +

+
+ + {board.inspectors.map((inspector) => ( + { setDraggingId(null); setHover(null); }} + onDragOverAxis={(minute) => setHover({ inspectorId: inspector.id, minute })} + onDragLeaveAxis={() => setHover(null)} + onDropAxis={(event) => dropOnColumn(inspector.id, event)} + slotIntervalMin={board.slotIntervalMin} + /> + ))} +
+
+ )} +
+
+ + setBlocked(null)} + /> + + ); +} diff --git a/app/components/dispatch/DispatchColumn.tsx b/app/components/dispatch/DispatchColumn.tsx new file mode 100644 index 000000000..473f6c03f --- /dev/null +++ b/app/components/dispatch/DispatchColumn.tsx @@ -0,0 +1,230 @@ +/** + * The board's column primitives: the shared hour gutter, one inspector's + * column, and the card that sits on it. + * + * Split out of `DispatchBoard.tsx` when that file crossed the 400-line gate. + * The seam is deliberate rather than arbitrary — everything here is presentation + * driven entirely by props, while the board keeps the state, the fetcher and + * the drop decisions. Nothing in this file knows what a drop MEANS. + */ +import { Link } from "react-router"; +import { m } from "~/paraglide/messages"; +import { + bucketColumn, + cardGeometry, + cardTone, + hourLabel, + inspectorLabel, + isDraggableItem, + minuteFromOffsetY, + minuteToHm, + offsetYFromMinute, + HOUR_HEIGHT_PX, + type DispatchInspector, + type DispatchItem, +} from "./dispatch-helpers"; + +export function TimeGutter({ hours, axisPx }: { hours: number[]; axisPx: number }) { + return ( +
+ {/* Two spacers, not one: the gutter has to line up with BOTH the column + heading and the all-day strip, or every card sits an all-day row off. */} +
+
+ {m.calendar_all_day()} +
+
+ {hours.map((hour, index) => { + const label = hourLabel(hour); + return ( + /* fg-3, not the day calendar's fg-4: fg-4 measured 3.07:1 against + the dark card surface, and an hour label is the one thing on this + axis a reader must be able to resolve. */ +
+ {label.hour12}:00 {label.meridiem} +
+ ); + })} +
+
+ ); +} + +export function InspectorColumn({ + inspector, + items, + hours, + axisPx, + draggingId, + hoverMinute, + onDragStartItem, + onDragEndItem, + onDragOverAxis, + onDragLeaveAxis, + onDropAxis, + slotIntervalMin, +}: { + inspector: DispatchInspector; + items: DispatchItem[]; + hours: number[]; + axisPx: number; + draggingId: string | null; + hoverMinute: number | null; + onDragStartItem: (id: string) => void; + onDragEndItem: () => void; + onDragOverAxis: (minute: number) => void; + onDragLeaveAxis: () => void; + onDropAxis: (event: React.DragEvent) => void; + slotIntervalMin: number; +}) { + const { timed, untimed } = bucketColumn(items, inspector.id); + + return ( +
+
+ + {inspectorLabel(inspector)} + + {timed.length + untimed.length} +
+ +
+ {untimed.map((item) => ( +
+ {item.title} +
+ ))} +
+ +
{ + if (!draggingId) return; + // Without preventDefault the browser refuses the drop outright — this + // is what makes the element a drop target, not just a hover surface. + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + onDragOverAxis(minuteFromOffsetY(event.clientY - rect.top, slotIntervalMin)); + }} + onDragLeave={onDragLeaveAxis} + onDrop={onDropAxis} + > + {hours.map((hour, index) => ( +
+ ))} + + {hoverMinute != null && ( +
+ + {minuteToHm(hoverMinute)} + +
+ )} + + {timed.length === 0 && untimed.length === 0 && ( +

+ {m.dispatch_empty_day()} +

+ )} + + {timed.map((item) => ( + + ))} +
+
+ ); +} + +function DispatchCard({ + item, + dragging, + onDragStartItem, + onDragEndItem, +}: { + item: DispatchItem; + dragging: boolean; + onDragStartItem: (id: string) => void; + onDragEndItem: () => void; +}) { + const geometry = cardGeometry(item); + if (!geometry) return null; + const draggable = isDraggableItem(item); + + const body = ( + <> + + {draggable && ( + + ⠿ + + )} + {item.title} + + + {item.startTime} + {item.endTime ? `-${item.endTime}` : ""} + {geometry.clippedStart ? ` ${m.dispatch_card_before_axis()}` : ""} + {geometry.clippedEnd ? ` ${m.dispatch_card_after_axis()}` : ""} + + + ); + + return ( +
{ + event.dataTransfer.setData("text/plain", item.id); + event.dataTransfer.effectAllowed = "move"; + onDragStartItem(item.id); + }} + onDragEnd={onDragEndItem} + className={`absolute inset-x-1 overflow-hidden rounded-lg px-2 py-1 text-[11px] font-bold ${cardTone(item.kind)}${dragging ? " opacity-40" : ""}`} + style={{ top: `${geometry.topPx}px`, height: `${geometry.heightPx}px` }} + > + {item.inspectionId ? ( + + {body} + + ) : ( + body + )} +
+ ); +} diff --git a/app/components/dispatch/FindATimeModal.test.tsx b/app/components/dispatch/FindATimeModal.test.tsx new file mode 100644 index 000000000..016c8863a --- /dev/null +++ b/app/components/dispatch/FindATimeModal.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom +/** + * Find-a-Time makes a promise: "this start is free for the whole job". + * + * The failure mode is silent and expensive — offering 09:00 for a three-hour + * inspection whose 10:00 is already taken sends someone to a house they will + * have to leave halfway through. So the assertions here are about what is NOT + * offered, and about the difference between "nothing is free" and "we could not + * find out", which look identical if a failed load is rendered as an empty day. + */ +import { describe, it, expect } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; + +import { FindATimeModal } from "./FindATimeModal"; +import { startsFittingDuration, type DaySlot } from "./dispatch-helpers"; + +const MEMBERS = [ + { id: "u-ada", name: "Ada" }, + { id: "u-bo", name: "Bo" }, +]; + +function slot(time: string, available: boolean, inspectorIds: string[] = []): DaySlot { + return { time, available, inspectorIds }; +} + +function renderModal(payload: unknown) { + const Stub = createRoutesStub([ + { + path: "/", + Component: () => ( + {}} + initialDate="2027-03-15" + members={MEMBERS} + onPick={() => {}} + /> + ), + }, + { path: "/resources/day-slots", loader: () => payload }, + ]); + return render(); +} + +const FULL_DAY = { + failed: false, + date: "2027-03-15", + intervalMin: 30, + slots: [ + slot("09:00", true, ["u-ada"]), + slot("09:30", true, ["u-ada", "u-bo"]), + slot("10:00", false), + slot("10:30", true, ["u-bo"]), + slot("11:00", true, ["u-bo"]), + ], + holidayAdvisory: null, +}; + +describe("FindATimeModal", () => { + it("offers only starts where the whole duration fits", async () => { + renderModal(FULL_DAY); + await waitFor(() => expect(screen.getAllByTestId("find-a-time-slot").length).toBeGreaterThan(0)); + const offered = screen.getAllByTestId("find-a-time-slot").map((b) => b.textContent); + // Default duration is 60 minutes = two consecutive free slots. + // 09:00+09:30 fits; 09:30 does not (10:00 is taken); 10:30+11:00 fits. + expect(offered.some((t) => t?.startsWith("09:00"))).toBe(true); + expect(offered.some((t) => t?.startsWith("09:30"))).toBe(false); + expect(offered.some((t) => t?.startsWith("10:30"))).toBe(true); + }); + + it("names the inspector when exactly one is free at that start", async () => { + renderModal(FULL_DAY); + await waitFor(() => expect(screen.getAllByTestId("find-a-time-slot").length).toBeGreaterThan(0)); + // Scoped to the results: "Ada" is also an option in the inspector filter. + const results = screen.getByTestId("find-a-time-results"); + expect(within(results).getAllByText("Ada").length).toBeGreaterThan(0); + }); + + it("says a lookup FAILED rather than showing an empty day", async () => { + renderModal({ failed: true, date: "2027-03-15", intervalMin: 30, slots: [], holidayAdvisory: null }); + await waitFor(() => + expect(screen.getByText("Availability could not be checked. Try again.")).toBeTruthy(), + ); + expect(screen.queryAllByTestId("find-a-time-slot")).toHaveLength(0); + }); + + it("says nothing fits when the day really is full", async () => { + renderModal({ failed: false, date: "2027-03-15", intervalMin: 30, slots: [slot("09:00", false)], holidayAdvisory: null }); + await waitFor(() => + expect(screen.getByText("No window that long is free on this day.")).toBeTruthy(), + ); + }); +}); + +describe("startsFittingDuration", () => { + const slots = FULL_DAY.slots; + + it("needs every consecutive slot the duration spans", () => { + expect([...startsFittingDuration(slots, 30, 30)]).toEqual(["09:00", "09:30", "10:30", "11:00"]); + expect([...startsFittingDuration(slots, 30, 60)]).toEqual(["09:00", "10:30"]); + expect([...startsFittingDuration(slots, 30, 90)]).toEqual([]); + }); + + it("refuses to step over a GAP in the grid", () => { + // 09:00 and 12:00 are both free, but the hours between them are not slots + // at all — a closed window. Index arithmetic alone would call this a + // three-hour opening. + const split = [slot("09:00", true, ["u-ada"]), slot("12:00", true, ["u-ada"])]; + expect([...startsFittingDuration(split, 30, 60)]).toEqual([]); + expect([...startsFittingDuration(split, 30, 30)]).toEqual(["09:00", "12:00"]); + }); + + it("rounds a duration that is not a whole number of slots UP", () => { + // 45 minutes on a 30-minute grid occupies two slots, not one. + expect([...startsFittingDuration(slots, 30, 45)]).toEqual(["09:00", "10:30"]); + }); +}); diff --git a/app/components/dispatch/FindATimeModal.tsx b/app/components/dispatch/FindATimeModal.tsx new file mode 100644 index 000000000..bf05b62ed --- /dev/null +++ b/app/components/dispatch/FindATimeModal.tsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useState } from "react"; +import { useFetcher } from "react-router"; +import { Button, Modal, Select } from "@core/shared-ui"; +import { m } from "~/paraglide/messages"; +import type { DaySlotsPayload } from "~/routes/resources/day-slots"; +import { startsFittingDuration } from "./dispatch-helpers"; + +export interface FindATimeMember { + id: string; + name: string; + email?: string; +} + +const DURATION_CHOICES = [60, 90, 120, 180, 240]; + +/** + * "When could this actually happen?" — the question the wizard's date picker + * cannot answer. + * + * Slots arrive through a route loader, never a browser `fetch('/api/…')`: the + * JWT lives in an HttpOnly cookie the React Router server relays, so a direct + * client call would be unauthenticated. And it is the STAFF slots endpoint, not + * the public booking one — the public surface deliberately withholds which + * inspector is free, which is the only part a dispatcher needs. + * + * A start is offered only when the whole DURATION fits from it. Showing a free + * 09:00 for a three-hour job whose 10:00 is taken would be a promise the + * calendar cannot keep. + */ +export function FindATimeModal({ + open, + onClose, + initialDate, + members, + onPick, +}: { + open: boolean; + onClose: () => void; + initialDate: string; + members: FindATimeMember[]; + onPick: (pick: { date: string; time: string; inspectorId: string | null }) => void; +}) { + const fetcher = useFetcher(); + const [date, setDate] = useState(initialDate); + const [durationMin, setDurationMin] = useState(DURATION_CHOICES[0]); + const [inspectorId, setInspectorId] = useState(""); + + useEffect(() => { if (open) setDate(initialDate); }, [open, initialDate]); + + useEffect(() => { + if (!open || !date) return; + const params = new URLSearchParams({ date }); + if (inspectorId) params.set("userIds", inspectorId); + fetcher.load(`/resources/day-slots?${params.toString()}`); + // The fetcher identity changes every render; depending on it would reload + // in a loop. The inputs below are the whole query. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, date, inspectorId]); + + const data = fetcher.data; + const slots = useMemo(() => data?.slots ?? [], [data]); + const fitting = useMemo( + () => startsFittingDuration(slots, data?.intervalMin ?? 30, durationMin), + [slots, data?.intervalMin, durationMin], + ); + + const loading = fetcher.state !== "idle"; + const memberName = (id: string) => + members.find((member) => member.id === id)?.name ?? id; + + return ( + {m.find_a_time_close()}} + > +
+ + + +
+ +
+ {data?.holidayAdvisory && ( +

+ {m.dispatch_closed_prefix()}: {data.holidayAdvisory.name} +

+ )} + + {loading &&

{m.find_a_time_loading()}

} + + {/* "Nothing is free" and "we could not find out" are different answers, + and only one of them means keep looking on this day. */} + {!loading && data?.failed && ( +

{m.find_a_time_failed()}

+ )} + + {!loading && data && !data.failed && fitting.size === 0 && ( +

{m.find_a_time_none()}

+ )} + + {!loading && fitting.size > 0 && ( +
+ {slots.filter((slot) => fitting.has(slot.time)).map((slot) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/app/components/dispatch/UnassignedLane.tsx b/app/components/dispatch/UnassignedLane.tsx new file mode 100644 index 000000000..ea76451c2 --- /dev/null +++ b/app/components/dispatch/UnassignedLane.tsx @@ -0,0 +1,110 @@ +import { Link } from "react-router"; +import { m } from "~/paraglide/messages"; +import { isDraggableItem, minutesOfDay, type DispatchItem } from "./dispatch-helpers"; + +/** + * The left rail: inspections on this day that nobody owns. + * + * It is a LANE, not a column — the cards carry no axis position because an + * unassigned job's time is exactly the thing still being decided. Sorting is + * by requested time when one exists so the rail reads like a queue, with + * timeless jobs last rather than first (a job with no time is the least urgent + * thing to place, not the most). + * + * It is also a drop target in both directions: dragging a card OUT places it on + * someone's day, dragging one IN takes the person off and leaves the time + * alone. Unassigning by dropping is the gesture a dispatcher already has for + * "I need to find someone else for this". + */ +export function UnassignedLane({ + items, + draggingId, + onDragStartItem, + onDragEndItem, + onDropItem, +}: { + items: DispatchItem[]; + draggingId: string | null; + onDragStartItem: (id: string) => void; + onDragEndItem: () => void; + onDropItem: (event: React.DragEvent) => void; +}) { + const sorted = [...items].sort((a, b) => { + const am = minutesOfDay(a.startTime); + const bm = minutesOfDay(b.startTime); + if (am == null && bm == null) return a.title.localeCompare(b.title); + if (am == null) return 1; + if (bm == null) return -1; + return am - bm; + }); + + return ( + + ); +} diff --git a/app/components/dispatch/dispatch-helpers.ts b/app/components/dispatch/dispatch-helpers.ts new file mode 100644 index 000000000..0cde436c9 --- /dev/null +++ b/app/components/dispatch/dispatch-helpers.ts @@ -0,0 +1,311 @@ +/** + * Dispatch board geometry and bucketing. + * + * Pure functions with no React and no `Date`: the board's placement rules are + * arithmetic over the wall-clock `HH:MM` strings the server already resolved in + * the TENANT timezone. Re-deriving a time here from an instant would reopen the + * calendar off-by-one — two dispatchers in different zones must see one card in + * one place, and the only string that guarantees that is the one the server sent. + */ + +export interface DispatchInspector { + id: string; + name: string | null; + email: string; + role: string; +} + +export interface DispatchItem { + id: string; + kind: string; + title: string; + start: string; + end: string; + civilDate: string; + startTime?: string; + endTime?: string; + allDay: boolean; + color?: string; + inspectionId?: string; + userId?: string; + meta?: Record; +} + +export interface DispatchPayload { + date: string; + conflictPolicy: "advisory" | "block"; + /** Tenant booking_slot_interval_min — the lattice a vertical drag snaps to. */ + slotIntervalMin: number; + /** Epoch ms of 00:00 on `date` in the TENANT timezone. */ + dayStartMs: number; + inspectors: DispatchInspector[]; + items: DispatchItem[]; + unassigned: DispatchItem[]; +} + +/** Axis bounds, in tenant wall-clock hours. Tenant-configurable later. */ +export const BOARD_START_HOUR = 7; +const BOARD_END_HOUR = 19; +/** One axis hour in pixels — matches the day calendar's row height. */ +export const HOUR_HEIGHT_PX = 56; +/** A card whose end instant was never stored still has to be grabbable. */ +const DEFAULT_CARD_MINUTES = 60; +/** Below this a card is a line, not a target. */ +const MIN_CARD_PX = 22; + +const AXIS_START_MIN = BOARD_START_HOUR * 60; +const AXIS_END_MIN = BOARD_END_HOUR * 60; + +/** Hour labels down the gutter, inclusive of the closing hour's line. */ +export function boardHours(): number[] { + const out: number[] = []; + for (let h = BOARD_START_HOUR; h < BOARD_END_HOUR; h++) out.push(h); + return out; +} + +/** `HH:MM` → minutes since midnight, or null when absent/malformed. */ +export function minutesOfDay(hhmm: string | undefined): number | null { + if (!hhmm) return null; + const match = /^(\d{2}):(\d{2})$/.exec(hhmm); + if (!match) return null; + const hours = Number(match[1]); + const mins = Number(match[2]); + if (hours > 23 || mins > 59) return null; + return hours * 60 + mins; +} + +/** 12-hour gutter label, assembled rather than formatted — see `lint:i18n`. */ +export function hourLabel(hour: number): { hour12: number; meridiem: "AM" | "PM" } { + const hour12 = hour % 12 === 0 ? 12 : hour % 12; + return { hour12, meridiem: hour >= 12 ? "PM" : "AM" }; +} + +export interface CardGeometry { + topPx: number; + heightPx: number; + /** The card really starts before the axis does — the top edge is a lie. */ + clippedStart: boolean; + /** The card really ends after the axis does. */ + clippedEnd: boolean; +} + +const clamp = (value: number, low: number, high: number) => + Math.min(Math.max(value, low), high); + +const pxFromAxis = (minute: number) => + ((minute - AXIS_START_MIN) / 60) * HOUR_HEIGHT_PX; + +/** + * Where a timed card sits on the axis. Returns null for all-day items and for + * anything with no usable start — those belong in the all-day strip, not at an + * arbitrary pixel. Cards outside the axis are CLAMPED rather than dropped: an + * inspection at 06:00 is exactly the thing a dispatcher needs to see, and + * hiding it because the axis starts at 07:00 would make the board lie. + */ +export function cardGeometry(item: DispatchItem): CardGeometry | null { + const startMin = minutesOfDay(item.startTime); + if (item.allDay || startMin == null) return null; + const endMin = Math.max( + minutesOfDay(item.endTime) ?? startMin + DEFAULT_CARD_MINUTES, + startMin + 1, + ); + + const top = clamp(startMin, AXIS_START_MIN, AXIS_END_MIN - 1); + const bottom = clamp(endMin, top + 1, AXIS_END_MIN); + + return { + topPx: pxFromAxis(top), + heightPx: Math.max(pxFromAxis(bottom) - pxFromAxis(top), MIN_CARD_PX), + clippedStart: startMin < AXIS_START_MIN, + clippedEnd: endMin > AXIS_END_MIN, + }; +} + +/** Total axis height, so the column and the gutter cannot disagree. */ +export function axisHeightPx(): number { + return (BOARD_END_HOUR - BOARD_START_HOUR) * HOUR_HEIGHT_PX; +} + +/** + * Company-wide closures. These carry no `userId`, so they are NOT a column's + * items — they grey the whole board. Unassigned inspections also carry no + * `userId`, which is why this keys on the kind and not on the absence. + */ +export function closureItems(items: DispatchItem[]): DispatchItem[] { + return items.filter((item) => item.kind === "company_holiday"); +} + +export interface ColumnBuckets { + /** Placeable on the axis, earliest first. */ + timed: DispatchItem[]; + /** All-day or untimed — rendered in the strip above the axis. */ + untimed: DispatchItem[]; +} + +/** + * One inspector's day. `userId` is the resolved owner the feed already worked + * out (link table first, legacy `inspections.inspector_id` as fallback), so the + * board never re-implements that precedence. + */ +export function bucketColumn(items: DispatchItem[], inspectorId: string): ColumnBuckets { + const mine = items.filter( + (item) => item.userId === inspectorId && item.kind !== "company_holiday", + ); + const timed = mine.filter((item) => cardGeometry(item) !== null); + const untimed = mine.filter((item) => cardGeometry(item) === null); + timed.sort((a, b) => (minutesOfDay(a.startTime) ?? 0) - (minutesOfDay(b.startTime) ?? 0)); + return { timed, untimed }; +} + +/** Design-system tone per item kind, mirroring the calendar's `eventColor`. */ +export function cardTone(kind: string): string { + if (kind === "calendar_block") return "bg-ih-fg-3 text-ih-fg-inverse"; + if (kind === "external_busy") return "bg-ih-fg-4 text-ih-fg-inverse"; + if (kind === "company_holiday") return "bg-ih-watch text-ih-fg-inverse"; + return "bg-ih-primary text-ih-fg-inverse"; +} + +/** Column heading — a name when there is one, the login otherwise. */ +export function inspectorLabel(inspector: DispatchInspector): string { + const name = inspector.name?.trim(); + return name ? name : inspector.email; +} + +/** One overlap the reschedule endpoint reported, mirroring ScheduleConflictSchema. */ +export interface ScheduleConflict { + inspectionId: string; + propertyAddress: string; + date: string; + inspectorId: string; +} + +/** + * What the route action hands back to the board after a drop. + * + * `ok: true` with a non-empty `conflicts` is the ADVISORY outcome — the write + * landed and the overlap is a warning. `ok: false` with SCHEDULE_CONFLICT is + * the BLOCK outcome — nothing was written. Collapsing the two into a single + * "there were conflicts" flag is how a board ends up telling a dispatcher a + * move succeeded when the server refused it. + */ +export interface RescheduleResult { + ok: boolean; + code?: string; + message?: string | null; + conflicts?: ScheduleConflict[]; +} + +/** A card the dispatcher may move. Blocks, busy time and closures are facts. */ +export function isDraggableItem(item: DispatchItem): boolean { + return item.kind === "inspection" && Boolean(item.inspectionId); +} + +/** + * Round a minute-of-day onto the tenant's booking lattice. + * + * Snapping to `booking_slot_interval_min` rather than to a pretty number is + * the point: a dragged job has to land on a time the booking engine would + * also have offered a customer, or the board quietly creates starts that no + * other surface in the product can produce. + */ +export function snapMinute(minute: number, intervalMin: number): number { + const step = intervalMin > 0 ? intervalMin : 30; + return Math.round(minute / step) * step; +} + +/** + * Pixel offset inside a column's axis → snapped minute-of-day, clamped so a + * drop near the bottom edge cannot produce a start after the axis ends. + */ +export function minuteFromOffsetY(offsetY: number, intervalMin: number): number { + const raw = AXIS_START_MIN + (offsetY / HOUR_HEIGHT_PX) * 60; + const snapped = snapMinute(raw, intervalMin); + return clamp(snapped, AXIS_START_MIN, AXIS_END_MIN); +} + +/** Axis pixel for a minute-of-day — the inverse of `minuteFromOffsetY`. */ +export function offsetYFromMinute(minute: number): number { + return pxFromAxis(clamp(minute, AXIS_START_MIN, AXIS_END_MIN)); +} + +/** Minute-of-day → instant, anchored on the tenant's own midnight. */ +export function minuteToEpochMs(dayStartMs: number, minute: number): number { + return dayStartMs + minute * 60_000; +} + +/** + * The instant a card currently occupies. The server layers the real + * `scheduledStartMs` onto every inspection it has one for; the wall-clock + * fallback exists for rows whose instant was never stored, so dropping such a + * card into the unassigned lane still has something to send. + */ +export function currentStartMs(item: DispatchItem, dayStartMs: number): number | null { + const stored = item.meta?.scheduledStartMs; + if (typeof stored === "number" && Number.isFinite(stored)) return stored; + const minute = minutesOfDay(item.startTime); + return minute == null ? null : minuteToEpochMs(dayStartMs, minute); +} + +/** `HH:MM` for a minute-of-day — assembled, never formatted (see `lint:i18n`). */ +export function minuteToHm(minute: number): string { + const h = Math.floor(minute / 60) % 24; + return `${String(h).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`; +} + +export interface DaySlot { + time: string; + available: boolean; + inspectorIds: string[]; +} + +/** + * Which slot STARTS can actually hold a job of `durationMin`. + * + * The slots endpoint reports starts, not windows — a 09:00 slot being free says + * nothing about 09:30, and offering "09:00" for a three-hour job whose 10:00 + * slot is taken is worse than offering nothing: it is a promise the calendar + * cannot keep. So a start qualifies only when every consecutive slot it needs + * exists, follows on at exactly `intervalMin`, and is free. The contiguity + * check is not paranoia: a gap in the grid is a closed window (lunch, a + * split shift), and index arithmetic alone would step straight over it. + */ +export function startsFittingDuration( + slots: DaySlot[], + intervalMin: number, + durationMin: number, +): Set { + const step = intervalMin > 0 ? intervalMin : 30; + const needed = Math.max(1, Math.ceil((durationMin > 0 ? durationMin : step) / step)); + const fits = new Set(); + + for (let i = 0; i < slots.length; i++) { + let ok = true; + for (let n = 0; n < needed; n++) { + const slot = slots[i + n]; + const previous = n === 0 ? null : slots[i + n - 1]; + if (!slot || !slot.available) { ok = false; break; } + if (previous) { + const gap = (minutesOfDay(slot.time) ?? 0) - (minutesOfDay(previous.time) ?? 0); + if (gap !== step) { ok = false; break; } + } + } + if (ok) fits.add(slots[i].time); + } + return fits; +} + +/** + * Shift a civil date by whole days without ever touching local time. Built on + * `Date.UTC` and read back with the UTC accessors, so the arithmetic happens in + * a zone with no DST and the result is a pure string transform. + */ +export function shiftCivilDate(date: string, days: number): string { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!match) return date; + const at = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))); + at.setUTCDate(at.getUTCDate() + days); + const y = at.getUTCFullYear(); + const m = String(at.getUTCMonth() + 1).padStart(2, "0"); + const d = String(at.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} diff --git a/app/components/inspector-portal/AddVisitModal.tsx b/app/components/inspector-portal/AddVisitModal.tsx new file mode 100644 index 000000000..685d6edfa --- /dev/null +++ b/app/components/inspector-portal/AddVisitModal.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { Button, Modal } from "@core/shared-ui"; +import { fromLocalInputValue } from "~/lib/datetime-local"; +import type { VisitTypeOption } from "./VisitsCard"; +import { m } from "~/paraglide/messages"; + +/** + * Choosing the next visit on a job. + * + * Its own file because `VisitsCard` crossed the 400-line ceiling with it + * inline, and because it is a genuinely separate decision: the card shows what + * has been committed to, this asks what to commit to next. + */ +export function AddVisitModal({ + open, + visitTypes, + suggestedTypeIds, + submitting, + onClose, + onAdd, +}: { + open: boolean; + visitTypes: VisitTypeOption[]; + suggestedTypeIds: string[]; + submitting: boolean; + onClose: () => void; + onAdd: (eventTypeId: string, scheduledAt: string, durationMin: number) => void; +}) { + const [eventTypeId, setEventTypeId] = useState(""); + const [when, setWhen] = useState(""); + const [durationMin, setDurationMin] = useState(30); + + if (!open) return null; + + const active = visitTypes.filter((t) => t.active); + const suggested = active.filter((t) => suggestedTypeIds.includes(t.id)); + const others = active.filter((t) => !suggestedTypeIds.includes(t.id)); + + // Picking a type seeds its own default duration, so the common case is no + // typing at all — a radon pickup is not a 30-minute job because 30 happened + // to be the control's initial value. + const choose = (id: string) => { + setEventTypeId(id); + setDurationMin(active.find((t) => t.id === id)?.defaultDurationMin ?? 30); + }; + + return ( + + + + + } + > + {active.length === 0 ? ( +

{m.inspections_hub_visits_types_empty()}

+ ) : ( +
+ + + +
+ )} +
+ ); +} diff --git a/app/components/inspector-portal/InvoiceCard.tsx b/app/components/inspector-portal/InvoiceCard.tsx index bd511f4ee..ff0a36989 100644 --- a/app/components/inspector-portal/InvoiceCard.tsx +++ b/app/components/inspector-portal/InvoiceCard.tsx @@ -22,6 +22,7 @@ import type { action } from "~/routes/inspector-portal"; export function InvoiceCard({ pill, amountCents, + currency, paid, sent, payUrl, @@ -31,9 +32,12 @@ export function InvoiceCard({ canManagePrice, onRequestPayment, }: { - pill: { tone: PillTone; label: string }; + /** `detail` carries the one-line money sentence a pill has no room for. */ + pill: { tone: PillTone; label: string; detail?: string }; /** IA-95 — undefined when the caller lacks the `financial` capability. */ amountCents: number | undefined; + /** The invoice's own ISO 4217 snapshot; every figure on this card uses it. */ + currency: string | undefined; paid: boolean; sent: boolean; payUrl: string | null | undefined; @@ -65,8 +69,15 @@ export function InvoiceCard({ invoice exists and its status, but not the figure. Saying so beats rendering $0.00, which reads as "nothing owed". */}

- {amountCents === undefined ? m.inspections_hub_invoice_hidden() : formatCents(amountCents)} + {amountCents === undefined ? m.inspections_hub_invoice_hidden() : formatCents(amountCents, { currency })}

+ {/* A partially paid invoice's outstanding balance. The total above is + what was billed; this is what is still owed, and without it the + only thing the card could say about a partial payment was that one + had happened. Absent whenever the number is not knowable. */} + {pill.detail && ( +

{pill.detail}

+ )} {hasServiceLines && (

{m.inspections_hub_invoice_from_services()}

)} diff --git a/app/components/inspector-portal/ReportsCard.tsx b/app/components/inspector-portal/ReportsCard.tsx new file mode 100644 index 000000000..f65950bae --- /dev/null +++ b/app/components/inspector-portal/ReportsCard.tsx @@ -0,0 +1,150 @@ +import { useState } from "react"; +import { useFetcher } from "react-router"; +import { Card, Pill } from "@core/shared-ui"; +import { BlockHeading } from "./BlockHeading"; +import { ConfirmDialog } from "~/components/ConfirmDialog"; +import { m } from "~/paraglide/messages"; +import type { action } from "~/routes/inspector-portal"; + +/** + * Mirrors the `reports` entry of `InspectionHubSchema`. Kept as a named export + * so the route's payload interface points at THIS rather than repeating the + * shape — a second hand-written copy of a payload is how `invoice.payUrl` went + * missing on the frontend for a release. + */ +export interface ReportRow { + id: string; + kind: "primary" | "ancillary"; + title: string; + status: string; + publishedAt: string | null; + versionCount: number; + hasContent: boolean; + canDelete: boolean; + deleteBlockedReason: "primary" | "published" | null; +} + +/** + * The order's deliverables. + * + * One order, several reports: a standard inspection publishes today and the + * radon report publishes on Thursday, each with its own document, its own + * signature chain and its own notification. Until this card existed the page + * showed a single report status pill derived from the inspection row, so an + * order carrying three documents looked exactly like an order carrying one. + * + * `canDelete` is READ, never re-derived. The rule lives in one function on the + * server (`reportDeleteBlock`) which the DELETE endpoint also enforces, so this + * card cannot offer an action the API refuses — and the disabled control states + * the reason rather than failing silently when clicked. + */ +export function ReportsCard({ + reports, + canManage, + formatDate, +}: { + reports: ReportRow[]; + canManage: boolean; + formatDate: (iso: string) => string; +}) { + const deleteFetcher = useFetcher(); + const [deleting, setDeleting] = useState(null); + + const busy = deleteFetcher.state !== "idle"; + const done = deleteFetcher.state === "idle" ? deleteFetcher.data : undefined; + const error = done && "ok" in done && !done.ok && done.intent === "report-delete" + ? done.error + : undefined; + + return ( + + + + {reports.length === 0 ? ( +

{m.inspections_hub_reports_empty()}

+ ) : ( +
    + {reports.map((report) => { + const published = report.publishedAt; + return ( +
  • + + + {report.title} + {report.kind === "primary" && ( + {m.inspections_hub_reports_primary()} + )} + + {published + ? m.inspections_hub_reports_status_published() + : m.inspections_hub_reports_status_in_progress()} + + + + {published && m.inspections_hub_reports_published_on({ date: formatDate(published) })} + {published && report.versionCount > 0 && " · "} + {report.versionCount === 1 && m.inspections_hub_reports_versions_one()} + {report.versionCount > 1 + && m.inspections_hub_reports_versions_other({ count: report.versionCount })} + + + + {canManage && ( + + )} +
  • + ); + })} +
+ )} + + {error &&

{error}

} + + {/* Names the report AND what is destroyed with it. A report is not a + row: it carries the content somebody filled in and its own + editing history, and none of it comes back. */} + setDeleting(null)} + onConfirm={() => { + if (!deleting) return; + deleteFetcher.submit( + { intent: "report-delete", reportId: deleting.id }, + { method: "post" }, + ); + setDeleting(null); + }} + /> +
+ ); +} + +function blockedReason(report: ReportRow): string | null { + if (report.deleteBlockedReason === "primary") return m.inspections_hub_reports_blocked_primary(); + if (report.deleteBlockedReason === "published") return m.inspections_hub_reports_blocked_published(); + return null; +} diff --git a/app/components/inspector-portal/ScheduleCard.tsx b/app/components/inspector-portal/ScheduleCard.tsx index 5cf206e90..115e8a9cf 100644 --- a/app/components/inspector-portal/ScheduleCard.tsx +++ b/app/components/inspector-portal/ScheduleCard.tsx @@ -3,6 +3,7 @@ import { useFetcher } from "react-router"; import { Card, Button, Modal } from "@core/shared-ui"; import { BlockHeading } from "./BlockHeading"; import { formatInspectionDateTime } from "~/lib/format-date"; +import { useInspectionDateTimeFormat } from "~/hooks/useSessionContext"; import { toLocalInputValue, fromLocalInputValue } from "~/lib/datetime-local"; import { m } from "~/paraglide/messages"; import type { action } from "~/routes/inspector-portal"; @@ -41,6 +42,7 @@ export function ScheduleCard({ members: TeamMember[]; displayTz: string; }) { + const fmt = useInspectionDateTimeFormat(); const [open, setOpen] = useState(false); const fetcher = useFetcher(); const saving = fetcher.state !== "idle"; @@ -75,7 +77,7 @@ export function ScheduleCard({

{date - ? formatInspectionDateTime(date, undefined, displayTz) + ? formatInspectionDateTime(date, undefined, displayTz, fmt) : m.inspections_hub_schedule_unscheduled()}

{/* Labelled. An inspector's name is often just their email, and a diff --git a/app/components/inspector-portal/SigningRequests.tsx b/app/components/inspector-portal/SigningRequests.tsx index b24884440..a963af60b 100644 --- a/app/components/inspector-portal/SigningRequests.tsx +++ b/app/components/inspector-portal/SigningRequests.tsx @@ -3,6 +3,7 @@ import { Pill, Button } from "@core/shared-ui"; import { RequestDetail } from "~/components/agreements/RequestDetail"; import { pillToneFor, pillLabelFor } from "~/components/agreements/agreements-helpers"; import { formatInspectionDateTime } from "~/lib/format-date"; +import { useInspectionDateTimeFormat } from "~/hooks/useSessionContext"; import { m } from "~/paraglide/messages"; /** @@ -76,6 +77,7 @@ export function SigningRequests({ /** Inspector pre-sign, offered only while the envelope is still pending. */ onPreSign: (requestId: string) => void; }) { + const fmt = useInspectionDateTimeFormat(); const [expandedId, setExpandedId] = useState(null); return ( @@ -98,7 +100,7 @@ export function SigningRequests({
{req.clientEmail} - {when && <> · {formatInspectionDateTime(when, undefined, displayTz)}} + {when && <> · {formatInspectionDateTime(when, undefined, displayTz, fmt)}}
diff --git a/app/components/inspector-portal/VisitsCard.tsx b/app/components/inspector-portal/VisitsCard.tsx new file mode 100644 index 000000000..9ee05b957 --- /dev/null +++ b/app/components/inspector-portal/VisitsCard.tsx @@ -0,0 +1,292 @@ +import { useState } from "react"; +import { useFetcher } from "react-router"; +import { Card, Button, Pill } from "@core/shared-ui"; +import { BlockHeading } from "./BlockHeading"; +import { ConfirmDialog } from "~/components/ConfirmDialog"; +import { isAdminRole } from "~/lib/access"; +import { AddVisitModal } from "./AddVisitModal"; +import { m } from "~/paraglide/messages"; +import { EVENT_STATUS } from "~/lib/status"; +import type { action } from "~/routes/inspector-portal"; + +export type VisitStatus = "scheduled" | "completed" | "results_received" | "cancelled"; + +/** + * One `inspection_events` row as the hub loader hands it over. The timestamps + * arrive as ISO strings (drizzle `timestamp_ms` → `Date` → JSON), which is why + * every one of them is formatted through the caller's `formatDate` rather than + * being sliced here. + */ +export interface VisitRowData { + id: string; + eventTypeId: string; + scheduledAt: string; + durationMin: number; + status: VisitStatus; + notes: string | null; + completedAt: string | null; + resultsReceivedAt: string | null; + cancelledAt: string | null; +} + +export interface VisitTypeOption { + id: string; + name: string; + slug: string; + defaultDurationMin: number | null; + color: string | null; + active: boolean; +} + +export type VisitAction = "complete" | "results" | "cancel"; + +/** + * Which verbs a viewer may see on a visit in a given state. + * + * ONE function, read by both the card and the row, because "capabilities come + * from one function, not from a page". Completion is the FIELD's own act — the + * inspector standing in the crawlspace is the person who knows the visit is + * over, so it is offered to every role. Recording that the lab results ARRIVED + * is an office act about a different event entirely (the sample reaching the + * lab is not the inspector finishing), so it is owner/manager only. + * + * This governs what the UI INVITES. The server is where it is enforced; the two + * must not be allowed to disagree, which is why this is a pure function a test + * can pin rather than a set of inline `&&`s. + */ +export function visitActions(role: string, status: VisitStatus): VisitAction[] { + const admin = isAdminRole(role); + if (status === EVENT_STATUS.SCHEDULED) return admin ? ["complete", "cancel"] : ["complete"]; + if (status === EVENT_STATUS.COMPLETED) return admin ? ["results", "cancel"] : []; + // results_received and cancelled are terminal: there is nothing left to offer. + return []; +} + +function statusLabel(status: VisitStatus): string { + if (status === EVENT_STATUS.COMPLETED) return m.label_status_completed(); + if (status === EVENT_STATUS.RESULTS_RECEIVED) return m.inspections_hub_visits_status_results(); + if (status === EVENT_STATUS.CANCELLED) return m.label_status_cancelled(); + return m.label_status_scheduled(); +} + +function statusTone(status: VisitStatus): "sat" | "monitor" | "neutral" { + if (status === EVENT_STATUS.RESULTS_RECEIVED) return "sat"; + if (status === EVENT_STATUS.CANCELLED) return "neutral"; + return "monitor"; +} + +/** + * One visit and the verbs its state allows. + * + * Exported on its own so the action matrix can be rendered in isolation: the + * question "does an inspector get offered results-received" is about this row, + * not about the page that contains it. + */ +export function VisitRow({ + visit, + typeName, + role, + formatDate, + onAction, + busy = false, +}: { + visit: VisitRowData; + typeName: string; + role: string; + formatDate: (iso: string) => string; + onAction: (action: VisitAction, visit: VisitRowData) => void; + busy?: boolean; +}) { + const actions = visitActions(role, visit.status); + + // The transition trail. `inspection_events` records WHEN each transition + // happened but not WHO made it — there is no actor column — so the row + // states the times it can prove and claims no attribution it cannot. + const trail = [ + visit.completedAt && m.inspections_hub_visits_completed_on({ date: formatDate(visit.completedAt) }), + visit.resultsReceivedAt + && m.inspections_hub_visits_results_on({ date: formatDate(visit.resultsReceivedAt) }), + visit.cancelledAt && m.inspections_hub_visits_cancelled_on({ date: formatDate(visit.cancelledAt) }), + ].filter(Boolean) as string[]; + + return ( +
  • + + + {typeName} + {statusLabel(visit.status)} + + + {formatDate(visit.scheduledAt)} + {trail.length > 0 && ` · ${trail.join(" · ")}`} + + + + {actions.length > 0 && ( + + {actions.includes("complete") && ( + + )} + {actions.includes("results") && ( + + )} + {actions.includes("cancel") && ( + + )} + + )} +
  • + ); +} + +/** + * The visits that make up this job. + * + * `inspection_events` has existed, with a full API and an automation trigger per + * transition, and NO frontend — which is why production holds zero rows. A radon + * job is a drop-off and a pickup two days apart; without this card the second + * half of it lived only in the inspector's head. + * + * The add picker leads with the visit types the order's own services imply + * (`services.default_event_type_slugs`), so booking a radon test proposes its + * drop-off and its pickup instead of leaving the user to remember them. A slug + * with no surviving event type is simply not proposed — same rule the server's + * `proposeEventsForService` uses. + */ +export function VisitsCard({ + visits, + visitTypes, + suggestedTypeIds, + role, + formatDate, +}: { + visits: VisitRowData[]; + visitTypes: VisitTypeOption[]; + suggestedTypeIds: string[]; + role: string; + formatDate: (iso: string) => string; +}) { + const statusFetcher = useFetcher(); + const addFetcher = useFetcher(); + const [addOpen, setAddOpen] = useState(false); + const [cancelling, setCancelling] = useState(null); + + const canManage = isAdminRole(role); + const busy = statusFetcher.state !== "idle" || addFetcher.state !== "idle"; + const typeName = (id: string) => visitTypes.find((t) => t.id === id)?.name ?? id; + + const error = [statusFetcher, addFetcher] + .map((f) => { + const d = f.state === "idle" ? f.data : undefined; + if (!d || !("ok" in d) || d.ok) return undefined; + return d.intent?.startsWith("visit-") ? d.error : undefined; + }) + .find(Boolean); + + const submitStatus = (visit: VisitRowData, status: VisitStatus) => + statusFetcher.submit( + { intent: "visit-status", eventId: visit.id, status }, + { method: "post" }, + ); + + const handleAction = (verb: VisitAction, visit: VisitRowData) => { + if (verb === "complete") return submitStatus(visit, "completed"); + if (verb === "results") return submitStatus(visit, "results_received"); + setCancelling(visit); + }; + + return ( + + + + {visits.length === 0 ? ( +

    {m.inspections_hub_visits_empty()}

    + ) : ( +
      + {visits.map((visit) => ( + + ))} +
    + )} + + {error &&

    {error}

    } + + {canManage && ( +
    + +
    + )} + + setAddOpen(false)} + onAdd={(eventTypeId, scheduledAt, durationMin) => { + addFetcher.submit( + { + intent: "visit-add", + eventTypeId, + scheduledAt, + durationMin: String(durationMin), + }, + { method: "post" }, + ); + setAddOpen(false); + }} + /> + + {/* Never window.confirm: a cancelled visit is a commitment withdrawn + from somebody's calendar, so the question names it. */} + setCancelling(null)} + onConfirm={() => { + if (!cancelling) return; + submitStatus(cancelling, "cancelled"); + setCancelling(null); + }} + /> +
    + ); +} diff --git a/app/components/inspector-portal/reports-card.test.tsx b/app/components/inspector-portal/reports-card.test.tsx new file mode 100644 index 000000000..cfb061262 --- /dev/null +++ b/app/components/inspector-portal/reports-card.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment happy-dom +/** + * The order's report list, and the one irreversible control on it. + * + * Two things are pinned. First, the delete confirmation NAMES what is lost: + * the report by title, and that the content already filled into it is + * destroyed. A generic "are you sure?" is the same dialog whether it is about + * to discard an empty draft or a day of somebody's fieldwork. + * + * Second, the card never re-derives who may delete what. `canDelete` and + * `deleteBlockedReason` come from the same server function the DELETE endpoint + * enforces, so a blocked row is disabled AND says why — the failure mode being + * guarded against is a button that looks live, does nothing, and explains + * nothing. + */ +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; + +import { ReportsCard, type ReportRow } from "~/components/inspector-portal/ReportsCard"; + +const PRIMARY: ReportRow = { + id: "rep-primary", kind: "primary", title: "Inspection Report", status: "in_progress", + publishedAt: null, versionCount: 0, hasContent: false, + canDelete: false, deleteBlockedReason: "primary", +}; +const SEWER: ReportRow = { + id: "rep-sewer", kind: "ancillary", title: "Sewer Scope", status: "in_progress", + publishedAt: null, versionCount: 0, hasContent: true, + canDelete: true, deleteBlockedReason: null, +}; +const RADON: ReportRow = { + id: "rep-radon", kind: "ancillary", title: "Radon Testing", status: "published", + publishedAt: "2026-08-03T12:00:00.000Z", versionCount: 1, hasContent: false, + canDelete: false, deleteBlockedReason: "published", +}; + +function renderCard(reports: ReportRow[], canManage = true) { + const calls: Record[] = []; + const Stub = createRoutesStub([ + { + path: "/hub", + Component: () => ( + `on ${iso.slice(0, 10)}`} /> + ), + action: async ({ request }) => { + const form = await request.formData(); + calls.push(Object.fromEntries(form) as Record); + return { ok: true, intent: "report-delete", error: undefined }; + }, + }, + ]); + render(); + return { calls }; +} + +const deleteButtonFor = (title: string) => + screen.getAllByTestId("hub-report-row") + .find((row) => row.textContent?.includes(title))! + .querySelector("button")!; + +describe("ReportsCard", () => { + it("lists every deliverable on the order", () => { + renderCard([PRIMARY, SEWER, RADON]); + expect(screen.getAllByTestId("hub-report-row")).toHaveLength(3); + expect(screen.getByText("Sewer Scope")).toBeTruthy(); + expect(screen.getByText("Radon Testing")).toBeTruthy(); + }); + + it("names the report and what is destroyed with it", async () => { + renderCard([PRIMARY, SEWER]); + fireEvent.click(deleteButtonFor("Sewer Scope")); + + const body = await screen.findByText(/Sewer Scope has information filled out in it/); + // The title alone is not "naming what is lost" — the sentence has to say + // the entered content goes, or the dialog is decoration. + expect(body.textContent).toMatch(/destroys that content/i); + expect(body.textContent).toMatch(/cannot be undone/i); + }); + + it("is honest when there is nothing filled in yet", async () => { + renderCard([PRIMARY, { ...SEWER, hasContent: false }]); + fireEvent.click(deleteButtonFor("Sewer Scope")); + expect(await screen.findByText(/has nothing filled out in it yet/)).toBeTruthy(); + }); + + it("submits the delete only after the confirmation is accepted", async () => { + const { calls } = renderCard([PRIMARY, SEWER]); + fireEvent.click(deleteButtonFor("Sewer Scope")); + expect(calls, "opening the dialog already deleted the report").toHaveLength(0); + + // Scoped to the dialog: the rows carry "Delete" buttons too, and a + // bare query that happened to grab a row button would pass while + // testing nothing about the confirmation. + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Delete" })); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0]).toMatchObject({ intent: "report-delete", reportId: "rep-sewer" }); + }); + + it("deletes nothing when the confirmation is cancelled", async () => { + const { calls } = renderCard([PRIMARY, SEWER]); + fireEvent.click(deleteButtonFor("Sewer Scope")); + fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(screen.queryByText(/cannot be undone/i)).toBeNull()); + expect(calls).toHaveLength(0); + }); + + it("disables a blocked row AND says why", () => { + renderCard([PRIMARY, RADON]); + + const primaryBtn = deleteButtonFor("Inspection Report"); + expect(primaryBtn.hasAttribute("disabled")).toBe(true); + expect(primaryBtn.getAttribute("aria-label")).toMatch(/primary report cannot be deleted/i); + + const radonBtn = deleteButtonFor("Radon Testing"); + expect(radonBtn.hasAttribute("disabled")).toBe(true); + expect(radonBtn.getAttribute("aria-label")).toMatch(/published report cannot be deleted/i); + }); + + it("offers no delete control at all without the manage capability", () => { + renderCard([PRIMARY, SEWER], false); + expect(screen.queryByRole("button", { name: "Delete" })).toBeNull(); + }); + + it("shows the empty state rather than an empty list", () => { + renderCard([]); + expect(screen.queryByTestId("hub-reports-list")).toBeNull(); + expect(screen.getByText(/No reports on this order yet/)).toBeTruthy(); + }); +}); diff --git a/app/components/inspector-portal/visits-card.test.tsx b/app/components/inspector-portal/visits-card.test.tsx new file mode 100644 index 000000000..465aba741 --- /dev/null +++ b/app/components/inspector-portal/visits-card.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom +/** + * `inspection_events` had a table, a full CRUD API and an automation trigger per + * transition — and no frontend, which is why production holds zero rows. + * + * The half of that worth pinning is not "does a list render" but WHO IS INVITED + * TO DO WHAT. Completing a visit is the field's own act: the inspector standing + * in the crawlspace is the person who knows it is over. Recording that the lab + * results ARRIVED is an office act about a different moment entirely — the + * sample reaching the lab is not the inspector finishing — and a card that + * offers it to an inspector is a card offering an action the server refuses. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; + +import { + VisitRow, + VisitsCard, + visitActions, + type VisitRowData, + type VisitTypeOption, +} from "./VisitsCard"; + +const TYPE: VisitTypeOption = { + id: "et-radon-pickup", + name: "Radon pickup", + slug: "radon_pickup", + defaultDurationMin: 20, + color: "#4a72ff", + active: true, +}; + +const SCHEDULED: VisitRowData = { + id: "ev1", + eventTypeId: TYPE.id, + scheduledAt: "2026-08-06T15:00:00.000Z", + durationMin: 20, + status: "scheduled", + notes: null, + completedAt: null, + resultsReceivedAt: null, + cancelledAt: null, +}; + +const COMPLETED: VisitRowData = { + ...SCHEDULED, + status: "completed", + completedAt: "2026-08-06T15:25:00.000Z", +}; + +const fmt = (iso: string) => iso.slice(0, 16); + +function renderRow(visit: VisitRowData, role: string) { + const Stub = createRoutesStub([ + { + path: "/", + Component: () => ( +
      + +
    + ), + }, + ]); + return render(); +} + +function renderCard(props: Partial[0]> = {}) { + const Stub = createRoutesStub([ + { + path: "/", + Component: () => ( + + ), + action: () => ({ ok: true }), + }, + ]); + return render(); +} + +describe("visitActions", () => { + it("offers completion to the field", () => { + expect(visitActions("inspector", "scheduled")).toContain("complete"); + }); + it("keeps results-received in the office", () => { + expect(visitActions("inspector", "completed")).not.toContain("results"); + expect(visitActions("owner", "completed")).toContain("results"); + expect(visitActions("manager", "completed")).toContain("results"); + }); + it("offers nothing on a terminal visit", () => { + expect(visitActions("owner", "results_received")).toEqual([]); + expect(visitActions("owner", "cancelled")).toEqual([]); + }); +}); + +describe("VisitRow", () => { + it("offers the complete action to an inspector", () => { + renderRow(SCHEDULED, "inspector"); + expect(screen.getByRole("button", { name: /complete/i })).toBeEnabled(); + }); + + it("does not offer results-received to an inspector", () => { + // Office action, different actor. The gate is enforced server-side; this + // only checks the UI does not invite it. + renderRow(COMPLETED, "inspector"); + expect(screen.queryByRole("button", { name: /results/i })).toBeNull(); + }); + + it("offers results-received to a manager once the visit is completed", () => { + renderRow(COMPLETED, "manager"); + expect(screen.getByRole("button", { name: /results/i })).toBeEnabled(); + }); + + it("shows the transition trail it can prove", () => { + renderRow(COMPLETED, "owner"); + expect(screen.getByTestId("hub-visit-row").textContent).toContain("2026-08-06T15:25"); + }); +}); + +describe("VisitsCard", () => { + it("says the inspection has no visits rather than rendering an empty list", () => { + renderCard({ visits: [] }); + expect(screen.queryByTestId("hub-visits-list")).toBeNull(); + expect(screen.getByText(/no visits/i)).toBeTruthy(); + }); + + it("names the visit by its type", () => { + renderCard(); + expect(screen.getByTestId("hub-visit-row").textContent).toContain("Radon pickup"); + }); + + it("does not offer the add verb to an inspector", () => { + renderCard({ role: "inspector" }); + expect(screen.queryByRole("button", { name: /add visit/i })).toBeNull(); + }); + + it("leads the picker with the visit types this inspection's services imply", () => { + const other: VisitTypeOption = { ...TYPE, id: "et-sewer", name: "Sewer scope", slug: "sewer_scope" }; + renderCard({ visitTypes: [other, TYPE], suggestedTypeIds: [TYPE.id] }); + fireEvent.click(screen.getByRole("button", { name: /add visit/i })); + const groups = Array.from(document.querySelectorAll("optgroup")).map((g) => g.label); + expect(groups[0]).toMatch(/suggested/i); + const suggestedOptions = Array.from( + document.querySelectorAll("optgroup")[0].querySelectorAll("option"), + ).map((o) => o.textContent); + expect(suggestedOptions).toEqual(["Radon pickup"]); + }); +}); diff --git a/app/components/invoices/InvoiceAmountCell.tsx b/app/components/invoices/InvoiceAmountCell.tsx new file mode 100644 index 000000000..47f537735 --- /dev/null +++ b/app/components/invoices/InvoiceAmountCell.tsx @@ -0,0 +1,46 @@ +import { formatCurrency } from "~/lib/format"; +import { remainingCents } from "~/lib/hub-blocks"; +import { m } from "~/paraglide/messages"; + +/** + * The Amount column of the invoices table. + * + * The column states what was BILLED. On a partially paid invoice that is not + * what is owed, and the status pill beside it can only say "partial" — so the + * outstanding figure goes here, under the total. + * + * Both figures render in the invoice's OWN snapshot currency, never the viewer's + * live default: a historical record must not get re-labelled when the tenant + * switches currency, and two amounts in one cell disagreeing about their unit + * would be worse than showing one. + * + * The balance is omitted — not zeroed — whenever it is unknowable: money + * redacted for this viewer, or a partial with no recorded amount. See + * `remainingCents`. + */ +export function InvoiceAmountCell({ + invoice, + currency: fallbackCurrency, + locale, +}: { + /** Structural — any row carrying these four fields, so the table's own + * `InvoiceRow` type stays private to the route. */ + invoice: { amountCents: number; amountPaidCents: number | null; status: string; currency: string }; + /** Used only when the row carries no snapshot currency of its own. */ + currency: string; + locale: string; +}) { + const { amountCents, status } = invoice; + const currency = invoice.currency || fallbackCurrency; + const remaining = status === "partial" ? remainingCents(invoice) : null; + return ( +
    + {formatCurrency(amountCents, { locale, currency })} + {remaining !== null && ( + + {m.label_hub_invoice_remaining({ amount: formatCurrency(remaining, { locale, currency }) })} + + )} +
    + ); +} diff --git a/app/components/invoices/PaymentsModal.test.tsx b/app/components/invoices/PaymentsModal.test.tsx new file mode 100644 index 000000000..733d8fd0b --- /dev/null +++ b/app/components/invoices/PaymentsModal.test.tsx @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +/** + * The staff payment surface. + * + * The one thing worth a test here is the thing the plan says will be + * "simplified" away: the DATE. It is visible, editable, pre-filled with today + * rather than assumed to be today, and what gets submitted for a past date is + * that past day — not the moment the form was posted. A surface that quietly + * stamped now() would pass every other assertion on this page. + * + * The balance is the second: it is derived from the ROWS against the invoice + * total, refunds subtracting, so a correction moves it without anything reading + * the cached column. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, fireEvent } from "@testing-library/react"; +import { PaymentsModal, type PaymentRow } from "./PaymentsModal"; + +const INVOICE = { id: "inv-1", clientName: "Dana Reyes", amountCents: 45000, currency: "USD" }; + +const CASH: PaymentRow = { + id: "pay-1", kind: "balance", amountCents: 20000, method: "cash", provider: null, + note: "at the door", occurredAt: "2026-03-03T09:00:00.000Z", + recordedBy: "u-1", recordedByName: "Dana Reyes", refundsId: null, +}; + +const CORRECTION: PaymentRow = { + id: "pay-2", kind: "refund", amountCents: 18000, method: "cash", provider: null, + note: "Correction: decimal typo", occurredAt: "2026-03-03T09:00:00.000Z", + recordedBy: "u-1", recordedByName: "Dana Reyes", refundsId: "pay-1", +}; + +function mockFetcher(data?: unknown) { + return { state: "idle" as const, data, submit: vi.fn(), load: vi.fn(), Form: () => null }; +} + +function renderModal(payments: PaymentRow[], fetcher = mockFetcher()) { + const utils = render( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + {}} />, + ); + return { ...utils, fetcher }; +} + +/** The browser's own calendar day, the same way the component computes it. */ +function todayLocal(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + +describe("PaymentsModal — the date", () => { + it("shows the date field, pre-filled with today and editable", () => { + const { container } = renderModal([]); + const date = container.querySelector('input[type="date"]') as HTMLInputElement; + expect(date).toBeTruthy(); + expect(date.value).toBe(todayLocal()); + expect(date.disabled).toBe(false); + expect(date.readOnly).toBe(false); + }); + + it("submits the day the money moved, not the moment the form was posted", () => { + // Tuesday's cash, recorded today. If the surface defaulted to now(), the + // submitted instant would land on today's date and this would fail. + const { container, getByText, fetcher } = renderModal([]); + fireEvent.change(container.querySelector('input[type="number"]')!, { target: { value: "200" } }); + fireEvent.change(container.querySelector('input[type="date"]')!, { target: { value: "2026-03-03" } }); + fireEvent.click(getByText("Record payment")); + + expect(fetcher.submit).toHaveBeenCalledTimes(1); + const sent = fetcher.submit.mock.calls[0][0] as Record; + expect(sent.intent).toBe("record-payment"); + expect(sent.amount).toBe("200"); + // A full instant on the wire, and it is Tuesday's — in the browser's own + // zone, which is the only place that mapping can honestly be made. + const submitted = new Date(sent.occurredAt); + expect(Number.isNaN(submitted.getTime())).toBe(false); + expect(submitted.getFullYear()).toBe(2026); + expect(submitted.getMonth()).toBe(2); + expect(submitted.getDate()).toBe(3); + // …and emphatically not today's, which is what a defaulted field would send. + expect(`${submitted.getFullYear()}-03-03`).not.toBe(todayLocal()); + }); + + it("will not offer a future day to pick", () => { + const { container } = renderModal([]); + const date = container.querySelector('input[type="date"]') as HTMLInputElement; + expect(date.getAttribute("max")).toBe(todayLocal()); + }); +}); + +describe("PaymentsModal — the ledger and the balance", () => { + it("makes the remaining balance the prominent figure", () => { + const { container } = renderModal([CASH]); + expect(container.textContent).toContain("$250.00"); // 45000 − 20000 remaining + expect(container.textContent).toContain("$450.00"); // invoice total, secondary + expect(container.textContent).toContain("$200.00"); // received + }); + + it("subtracts a correction from the balance without reading a cached total", () => { + const { container } = renderModal([CASH, CORRECTION]); + // 20000 received, 18000 corrected away → 2000 net, 43000 still owed. + expect(container.textContent).toContain("$430.00"); + expect(container.textContent).toContain("$20.00"); + }); + + it("keeps the original visible with the correction below it", () => { + const { container } = renderModal([CASH, CORRECTION]); + const items = [...container.querySelectorAll("li")]; + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain("$200.00"); + expect(items[0].textContent).toContain("at the door"); + expect(items[1].textContent).toContain("$180.00"); + expect(items[1].textContent).toContain("decimal typo"); + }); + + it("names who recorded each row — the question a dispute turns on", () => { + const { container } = renderModal([CASH]); + expect(container.textContent).toContain("Recorded by Dana Reyes"); + expect(container.textContent).toContain("Cash"); + }); + + it("offers the correction control on exactly the row that can take one", () => { + // Not on a correction (it reverses, it is not reversed), not on a + // provider row (that money is reconciled elsewhere), and not on a row + // that already carries a correction — that click would only earn a 409. + const provider: PaymentRow = { ...CASH, id: "pay-3", provider: "stripe", method: "card", recordedByName: null }; + const cheque: PaymentRow = { ...CASH, id: "pay-4", amountCents: 10000, method: "check", note: "Cheque 4471" }; + + const corrected = renderModal([CASH, CORRECTION, provider]); + expect([...corrected.container.querySelectorAll("button")].filter((b) => b.textContent === "Correct")).toHaveLength(0); + + const open = renderModal([CASH, CORRECTION, provider, cheque]); + const controls = [...open.container.querySelectorAll("li")] + .filter((li) => [...li.querySelectorAll("button")].some((b) => b.textContent === "Correct")); + expect(controls).toHaveLength(1); + expect(controls[0].textContent).toContain("Cheque 4471"); + }); + + it("says so plainly when nothing has been recorded", () => { + const { container } = renderModal([]); + expect(container.textContent).toContain("No payments recorded yet."); + }); +}); + +describe("PaymentsModal — overpayment", () => { + it("offers a deliberate confirm only after the endpoint refuses one", () => { + const clean = renderModal([]); + expect([...clean.container.querySelectorAll("button")].some((b) => b.textContent === "Record it anyway")).toBe(false); + + const refused = renderModal([], mockFetcher({ + intent: "record-payment", ok: false, + error: "This payment exceeds the outstanding balance on this invoice (25000 cents remaining).", + })); + const anyway = [...refused.container.querySelectorAll("button")].find((b) => b.textContent === "Record it anyway"); + expect(anyway).toBeTruthy(); + + fireEvent.click(anyway!); + const sent = refused.fetcher.submit.mock.calls[0][0] as Record; + expect(sent.allowOverpayment).toBe("1"); + }); +}); diff --git a/app/components/invoices/PaymentsModal.tsx b/app/components/invoices/PaymentsModal.tsx new file mode 100644 index 000000000..0702eff5a --- /dev/null +++ b/app/components/invoices/PaymentsModal.tsx @@ -0,0 +1,305 @@ +import { useState } from "react"; +import type { useFetcher } from "react-router"; +import { Modal, Button, Input, Select, Banner } from "@core/shared-ui"; +import { formatCurrency, formatDate } from "~/lib/format"; +import { m } from "~/paraglide/messages"; + +/** + * The staff payment surface for one invoice. + * + * Deliberately the STAFF list, not the client portal or checkout. Recording + * money is capability-gated on `financial` and attributed to the acting user; + * the client-facing surfaces have no such actor and must never offer the form. + * Those two also deliberately quote the full invoice total, which is a settled + * payment-collection decision this surface does not touch. + * + * A form, not a modal chain: amount, method, date and an optional note are all + * visible at once, because a chain hides the field that matters most. + */ + +export type PaymentRow = { + id: string; + kind: "deposit" | "balance" | "adjustment" | "refund"; + amountCents: number; + method: string; + provider: string | null; + note: string | null; + /** ISO-8601 instant the money MOVED, not when the row was written. */ + occurredAt: string; + recordedBy: string | null; + recordedByName: string | null; + refundsId: string | null; +}; + +/** Only the invoice fields this surface reads; the page passes its own row. */ +type PaymentsInvoice = { + id: string; + clientName: string | null; + amountCents: number; + currency: string; +}; + +type ActionData = { intent?: unknown; ok?: boolean; error?: string | null } | undefined; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Fetcher = ReturnType>; + +interface Props { + invoice: PaymentsInvoice | null; + payments: PaymentRow[]; + loading: boolean; + fetcher: Fetcher; + locale: string; + onClose: () => void; +} + +function methodLabel(method: string): string { + const labels: Record = { + card: m.invoices_method_label_card(), + check: m.invoices_method_label_check(), + cash: m.invoices_method_label_cash(), + offline: m.invoices_method_label_offline(), + other: m.invoices_method_label_other(), + }; + return labels[method] ?? method; +} + +function payMethodOptions() { + return [ + { value: "cash", label: m.invoices_pay_method_cash() }, + { value: "check", label: m.invoices_pay_method_check() }, + { value: "offline", label: m.invoices_pay_method_offline() }, + { value: "other", label: m.invoices_pay_method_other() }, + ]; +} + +/** Today as the browser's own calendar day — the value a date input expects. */ +function todayLocal(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + +/** + * The picker gives a calendar DAY; the ledger stores an INSTANT. The conversion + * has to happen in the browser, because only the browser knows which zone that + * day belongs to. Local midnight of a day that has already begun is always in + * the past, so "today" can never trip the endpoint's no-future rule. + */ +function civilDayToInstant(day: string): string { + return new Date(`${day}T00:00:00`).toISOString(); +} + +export function PaymentsModal({ invoice, payments, loading, fetcher, locale, onClose }: Props) { + const [amount, setAmount] = useState(""); + const [method, setMethod] = useState("cash"); + const [occurredOn, setOccurredOn] = useState(todayLocal()); + const [note, setNote] = useState(""); + const [correcting, setCorrecting] = useState(null); + const [correctedAmount, setCorrectedAmount] = useState(""); + const [reason, setReason] = useState(""); + + if (!invoice) return null; + const currency = invoice.currency; + const data = fetcher.data as ActionData; + const busy = fetcher.state !== "idle"; + + // Receipts add, refunds subtract — the same one rule the ledger applies. A + // correction is a refund-kind row, so it lands here without a special case. + const receivedCents = payments.reduce( + (sum, p) => sum + (p.kind === "refund" ? -p.amountCents : p.amountCents), + 0, + ); + // The inspector's question is "how much is still owed", not "how much has + // been paid" — so this is the figure that gets the size. + const remainingCents = invoice.amountCents - receivedCents; + + // The endpoint refuses an overpayment until it is confirmed, because the same + // input is far more often a decimal-point typo than a client rounding up. + const overpaymentRefused = + data?.intent === "record-payment" && data.ok === false && /exceeds/i.test(data.error ?? ""); + + function submitPayment(allowOverpayment: boolean) { + fetcher.submit( + { + intent: "record-payment", + id: invoice!.id, + amount, + method, + occurredAt: occurredOn ? civilDayToInstant(occurredOn) : "", + note, + allowOverpayment: allowOverpayment ? "1" : "", + }, + { method: "post" }, + ); + } + + function submitCorrection(paymentId: string) { + fetcher.submit( + { intent: "correct-payment", id: invoice!.id, paymentId, amount: correctedAmount, reason }, + { method: "post" }, + ); + setCorrecting(null); + setCorrectedAmount(""); + setReason(""); + } + + return ( + {m.common_close()}} + > +
    + {/* Step 3 — the remaining balance is the prominent figure, formatted + through the shared money formatter with the INVOICE's own currency + snapshot rather than the tenant's live setting. */} +
    + {/* fg-2, not fg-3: on the MUTED panel rather than the card, fg-3 + measures 4.34:1 in light mode — under AA for text this size. */} +
    + {m.invoices_payments_remaining()} +
    +
    + {formatCurrency(remainingCents, { locale, currency })} +
    +
    + {m.invoices_payments_total_label()} {formatCurrency(invoice.amountCents, { locale, currency })} + {" · "} + {m.invoices_payments_received_label()} {formatCurrency(receivedCents, { locale, currency })} +
    +
    + + {data?.ok === false && data.error && {data.error}} + {overpaymentRefused && ( + + )} + + {/* Step 2 — the rows, not just a total. Once an invoice can hold several + payments, "paid $250" stops being the whole story, and a disputed + payment is only answerable if amount, method, date and recorder are + all on the page. */} +
    +

    + {m.invoices_payments_ledger_title()} +

    + {loading && payments.length === 0 ? ( +

    {m.common_loading()}

    + ) : payments.length === 0 ? ( +

    {m.invoices_payments_empty()}

    + ) : ( +
      + {payments.map((p) => { + const isCorrection = p.kind === "refund"; + // A payment can be corrected once. Offering the control on a row + // that already carries a correction would only earn a 409, and + // the correction is right there on the page saying so. + const corrected = payments.some((other) => other.refundsId === p.id); + return ( +
    • +
      + + {isCorrection ? "−" : ""} + {formatCurrency(p.amountCents, { locale, currency })} + + + {methodLabel(p.method)} · {formatDate(p.occurredAt, { locale })} + +
      +
      + + {p.recordedByName + ? m.invoices_payments_recorded_by({ name: p.recordedByName }) + : m.invoices_payments_recorded_automatically()} + + {!isCorrection && !p.provider && !corrected && ( + + )} +
      + {/* A long note must not break the row — it wraps and the + row grows, rather than pushing the amount off the end. */} + {p.note && ( +

      {p.note}

      + )} + {correcting === p.id && ( +
      +

      {m.invoices_payments_correct_hint()}

      + setCorrectedAmount(e.target.value)} + /> + setReason(e.target.value)} + /> +
      + + +
      +
      + )} +
    • + ); + })} +
    + )} +
    + + {/* Step 1 — the date is VISIBLE and EDITABLE, pre-filled with today + rather than assumed to be today. Tuesday's cash is recorded on + Thursday, and a hidden date makes every reporting period wrong. */} +
    +

    + {m.invoices_payments_record_title()} +

    +
    + setAmount(e.target.value)} + /> + setOccurredOn(e.target.value)} + /> +
    + setNote(e.target.value)} + /> +
    + +
    +
    +
    +
    + ); +} diff --git a/app/components/new-inspection/ConfirmStep.tsx b/app/components/new-inspection/ConfirmStep.tsx index 48688ad86..1b2a6accc 100644 --- a/app/components/new-inspection/ConfirmStep.tsx +++ b/app/components/new-inspection/ConfirmStep.tsx @@ -3,6 +3,7 @@ import type { WizardTeamMember } from "../NewInspectionWizard"; import { ScheduleStep } from "./ScheduleStep"; import { TeamStep } from "./TeamStep"; import { m } from "~/paraglide/messages"; +import { FindATimeLauncher } from "./FindATimeLauncher"; type ConflictFetcher = ReturnType< typeof useFetcher<{ @@ -59,9 +60,26 @@ export function ConfirmStep({ return (
    -

    - {m.new_inspection_step_schedule()} -

    +
    +

    + {m.new_inspection_step_schedule()} +

    + {/* The picker below asks "when do you want it"; this asks + "when could it actually happen". It lives here because a + chosen slot writes THREE of this step's fields at once. */} + { + setDate(pick.date); + setTime(pick.time); + if (pick.inspectorId) { + setInspectorId(pick.inspectorId); + setSoloMode(false); + } + }} + /> +
    void; +}) { + const [open, setOpen] = useState(false); + + return ( + <> +
    + +
    + setOpen(false)} + initialDate={date} + members={teamMembers} + onPick={onPick} + /> + + ); +} diff --git a/app/components/new-inspection/ReviewPanel.test.tsx b/app/components/new-inspection/ReviewPanel.test.tsx index 9fde601d7..cc1569438 100644 --- a/app/components/new-inspection/ReviewPanel.test.tsx +++ b/app/components/new-inspection/ReviewPanel.test.tsx @@ -4,6 +4,13 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import { ReviewPanel } from "./ReviewPanel"; import type { NewInspectionSummary } from "~/lib/wizard-review"; +// The panel renders a scheduled datetime, so it reads the session display +// preferences (#270). These hooks go through useRouteLoaderData, which throws +// outside a data router — this suite renders bare, so it stubs them. +vi.mock("~/hooks/useSessionContext", () => ({ + useInspectionDateTimeFormat: () => ({ locale: "en-US", dateFormat: "us", timeFormat: "12h" }), +})); + afterEach(cleanup); const FULL: NewInspectionSummary = { diff --git a/app/components/new-inspection/ReviewPanel.tsx b/app/components/new-inspection/ReviewPanel.tsx index aade794af..dcc8d42a2 100644 --- a/app/components/new-inspection/ReviewPanel.tsx +++ b/app/components/new-inspection/ReviewPanel.tsx @@ -1,6 +1,7 @@ import { formatPriceCents, type WizardStepId } from "~/lib/wizard-steps"; import type { NewInspectionSummary } from "~/lib/wizard-review"; import { formatInspectionDateTime } from "~/lib/format-date"; +import { useInspectionDateTimeFormat } from "~/hooks/useSessionContext"; import { m } from "~/paraglide/messages"; /** @@ -56,6 +57,7 @@ export function ReviewPanel({ currentStep: WizardStepId; onJump: (step: WizardStepId) => void; }) { + const fmt = useInspectionDateTimeFormat(); const row = (step: WizardStepId) => ({ step, onJump, isCurrent: currentStep === step }); return ( @@ -73,7 +75,7 @@ export function ReviewPanel({ {scheduledIso && ( )} diff --git a/app/components/new-inspection/WizardLayout.test.tsx b/app/components/new-inspection/WizardLayout.test.tsx new file mode 100644 index 000000000..9f66e1c4c --- /dev/null +++ b/app/components/new-inspection/WizardLayout.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom +/** + * The in-flight affordance (portal #105). + * + * The guard in `useGuardedSubmit` stops the second click from reaching the + * server, but on its own it makes the button LOOK broken: the inspector clicks + * Create, nothing about the page changes, and clicking again is the reasonable + * next thing to do. Disabling the button and spinning it is the half that tells + * them why the page went quiet. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { WizardLayout } from "./WizardLayout"; + +afterEach(cleanup); + +function renderLayout(props: { busy?: boolean; blockedReason?: string | null }) { + return render( + s} + blockedReason={props.blockedReason ?? null} + busy={props.busy} + isLastStep + onBack={vi.fn()} + onNext={vi.fn()} + review={
    review
    } + > +
    body
    +
    , + ); +} + +const createButton = (c: HTMLElement) => + ([...c.querySelectorAll("button")] as HTMLButtonElement[]).at(-1)!; + +describe("WizardLayout — submit-in-flight", () => { + it("leaves the submit button live and quiet when nothing is in flight", () => { + const { container } = renderLayout({}); + const btn = createButton(container); + expect(btn.hasAttribute("disabled")).toBe(false); + expect(btn.getAttribute("aria-busy")).toBeNull(); + expect(container.querySelector('[data-testid="wizard-next-spinner"]')).toBeNull(); + }); + + it("disables the submit button and spins it while a submit is in flight", () => { + const { container } = renderLayout({ busy: true }); + const btn = createButton(container); + expect(btn.hasAttribute("disabled")).toBe(true); + expect(btn.getAttribute("aria-busy")).toBe("true"); + expect(container.querySelector('[data-testid="wizard-next-spinner"]')).toBeTruthy(); + }); +}); diff --git a/app/components/new-inspection/WizardLayout.tsx b/app/components/new-inspection/WizardLayout.tsx index 1be5c940e..f0efb29a7 100644 --- a/app/components/new-inspection/WizardLayout.tsx +++ b/app/components/new-inspection/WizardLayout.tsx @@ -23,6 +23,7 @@ export function WizardLayout({ stepIdx, stepLabel, blockedReason, + busy = false, isLastStep, onBack, onNext, @@ -34,6 +35,12 @@ export function WizardLayout({ stepLabel: (step: WizardStepId) => string; /** Why Next is disabled, or null when it is not. */ blockedReason: string | null; + /** + * A submit is in flight (portal #105). The button goes dead and spins rather + * than sitting there looking untouched while nothing visibly happens — that + * silence is what got clicked three times in production. + */ + busy?: boolean; isLastStep: boolean; /** Back on any step past the first; Cancel on the first — one way out, not two. */ onBack: () => void; @@ -101,11 +108,22 @@ export function WizardLayout({

    )}
    diff --git a/app/components/notifications/NotificationPreferences.tsx b/app/components/notifications/NotificationPreferences.tsx index 588739a98..6b6074e47 100644 --- a/app/components/notifications/NotificationPreferences.tsx +++ b/app/components/notifications/NotificationPreferences.tsx @@ -230,7 +230,12 @@ export function NotificationPreferences({ // screen-reader user gets row/column context, and the header // row is only a visual convenience for everyone else.
    -
    + {/* Channel columns are 6rem, not 5: the widest header is + the localized channel name, and es-419 "Correo + electrónico" measures 85px — it outgrew a 5rem column + and bled into the next one. Sized for the label, not + for the English word "Email". */} +
    {bulk && bulkStateOf(youChoose, {}) && ( <> @@ -267,7 +272,7 @@ export function NotificationPreferences({
    {bulk && bulkStateOf(youChoose, { classId: row.id }) && ( diff --git a/app/components/portal/sections/AgreementSection.test.tsx b/app/components/portal/sections/AgreementSection.test.tsx index 41da00144..72dcdf08c 100644 --- a/app/components/portal/sections/AgreementSection.test.tsx +++ b/app/components/portal/sections/AgreementSection.test.tsx @@ -64,3 +64,48 @@ describe('AgreementSection — verify link (IA-46)', () => { expect(container.querySelector('a[href^="/verify/"]')).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// Language disclosure — beside the agreement, never inside it. +// --------------------------------------------------------------------------- + +function unsigned(): AgreementData { + return signed({ + status: 'sent', + signer: { name: 'Jane', role: 'client', status: 'sent' }, + progress: { signed: 0, total: 1 }, + }); +} + +describe('AgreementSection — language disclosure', () => { + it('shows it to a signer who has not signed yet', () => { + const { container } = renderSection(unsigned()); + const note = container.querySelector('[data-testid="agreement-language-disclosure"]'); + expect(note).not.toBeNull(); + expect(note!.textContent).toMatch(/provided in English/i); + }); + + it('still shows it after signing — the screen keeps saying what the record says', () => { + const { container } = renderSection(signed()); + expect(container.querySelector('[data-testid="agreement-language-disclosure"]')).not.toBeNull(); + }); + + it('renders it OUTSIDE the agreement body, not within it', () => { + // "Append it at render" and "append it to the body" look identical on a + // screenshot and are completely different legally: the body is the tenant's + // contract, which we write no word of. Nesting is the failure this catches. + const { container } = renderSection(unsigned()); + const body = container.querySelector('[data-testid="agreement-body"]'); + const note = container.querySelector('[data-testid="agreement-language-disclosure"]'); + expect(body).not.toBeNull(); + expect(note).not.toBeNull(); + expect(body!.contains(note!)).toBe(false); + expect(body!.textContent).not.toMatch(/provided in English/i); + }); + + it('leaves the agreement content itself untouched', () => { + const { container } = renderSection(unsigned()); + const body = container.querySelector('[data-testid="agreement-body"]'); + expect(body!.textContent?.trim()).toBe('terms'); + }); +}); diff --git a/app/components/portal/sections/AgreementSection.tsx b/app/components/portal/sections/AgreementSection.tsx index b713a2137..407188098 100644 --- a/app/components/portal/sections/AgreementSection.tsx +++ b/app/components/portal/sections/AgreementSection.tsx @@ -26,6 +26,7 @@ import { useState, useRef, useEffect } from "react"; import { useFetcher } from "react-router"; import { m } from "~/paraglide/messages"; import { SanitizedHtml } from "~/components/SanitizedHtml"; +import { AgreementLanguageDisclosure } from "~/components/agreements/AgreementLanguageDisclosure"; import { SignaturePad, type SignaturePadHandle } from "~/components/media-studio/SignaturePad"; import { OnBehalfFields, @@ -209,14 +210,21 @@ export function AgreementSection({ )}
    - {/* Agreement content */} -
    + {/* Agreement content — tenant data, and the only thing being signed. */} +
    + {/* Platform disclosure — a sibling of the agreement, outside its scroll + region and never composed into it. */} + + {/* Signature area */} {alreadySigned || signed ? (
    diff --git a/app/components/portal/sections/InvoiceDisplay.tsx b/app/components/portal/sections/InvoiceDisplay.tsx index 2771e0893..92d0e92cb 100644 --- a/app/components/portal/sections/InvoiceDisplay.tsx +++ b/app/components/portal/sections/InvoiceDisplay.tsx @@ -31,6 +31,14 @@ export function InvoiceDisplay({ invoice, brand, inspectionId, portalToken, just const total = invoice.total; const isPaid = invoice.status === "paid"; const isVoid = invoice.status === "void"; + // The full total, deliberately, even when the payment ledger says part of it + // has already arrived. The Stripe intent is minted server-side from the + // invoice's own `amountCents`, so showing a reduced balance here without + // reducing the charge would quote a price the payer is not about to be + // charged — worse than quoting the whole thing. Moving the two together is + // payment-COLLECTION behaviour (partial-payment minimums, over/underpayment, + // what a second intent means for the first), which the ledger enables but + // does not decide; it needs its own plan. Same call in checkout's PayCard. const balanceDue = isPaid ? 0 : total; const payable = !isPaid && !isVoid && balanceDue > 0; // IA-89 — Stripe has redirected back but the webhook has not settled the diff --git a/app/components/portal/sections/ReportView.tsx b/app/components/portal/sections/ReportView.tsx index dbcc12856..a1737a28b 100644 --- a/app/components/portal/sections/ReportView.tsx +++ b/app/components/portal/sections/ReportView.tsx @@ -20,7 +20,7 @@ import { useState } from "react"; import { m } from "~/paraglide/messages"; import { usePdfExport, pdfActionLabel, pdfBusyHint } from "~/hooks/usePdfExport"; -import { brandTokens } from "~/lib/brand"; +import { brandFormat, brandTokens } from "~/lib/brand"; import { presetTokens } from "~/lib/report-style/preset-tokens"; import { formatInspectionDateTime } from "~/lib/format-date"; import { ErrorState } from "~/components/ErrorState"; @@ -470,7 +470,7 @@ export function ReportView(props: ReportViewProps) { )}

    - {data.date ? `${formatInspectionDateTime(data.date, undefined, data.reportTimeZone)} · ` : ""} + {data.date ? `${formatInspectionDateTime(data.date, undefined, data.reportTimeZone, brandFormat(data.brand))} · ` : ""} {m.report_view_inspector({ name: data.inspectorName || m.report_view_na() })}

    {data.inspectorCredentials && data.inspectorCredentials.length > 0 && ( diff --git a/app/components/settings/ConnectionTestStatus.tsx b/app/components/settings/ConnectionTestStatus.tsx index abc672c87..cb2553ca6 100644 --- a/app/components/settings/ConnectionTestStatus.tsx +++ b/app/components/settings/ConnectionTestStatus.tsx @@ -11,12 +11,29 @@ */ import { useMemo } from "react"; import type { ConnectionTestResult } from "~/lib/connection-test"; +import { useChromeDateTimeFormat, useDisplayTimeZone } from "~/hooks/useSessionContext"; +import { + formatShapedDate, + formatShapedDateTime, + type InspectionDateTimeFormat, +} from "~/lib/format-date"; import { m } from "~/paraglide/messages"; export type { ConnectionTestResult }; +/** + * Zone + shape are threaded in rather than read from a hook here so these stay + * pure. Both come from the CHROME resolution (#270): a connection test is an + * admin looking at their own settings page, not a value a client and an agent + * read to each other, so the personal override applies. + */ +interface Display { + timeZone: string; + fmt: InspectionDateTimeFormat; +} + /** Compact relative time: "just now", "5m ago", "3h ago", "2d ago", else a date. */ -function relativeTime(epochMs: number, nowMs: number): string { +function relativeTime(epochMs: number, nowMs: number, d: Display): string { const diff = Math.max(0, nowMs - epochMs); const min = Math.floor(diff / 60_000); if (min < 1) return m.settings_conn_time_just_now(); @@ -25,11 +42,11 @@ function relativeTime(epochMs: number, nowMs: number): string { if (hr < 24) return m.settings_conn_time_hours({ hr }); const day = Math.floor(hr / 24); if (day < 7) return m.settings_conn_time_days({ day }); - return new Date(epochMs).toLocaleDateString(); + return formatShapedDate(epochMs, d.timeZone, d.fmt); } -function absoluteTime(epochMs: number): string { - return new Date(epochMs).toLocaleString(); +function absoluteTime(epochMs: number, d: Display): string { + return formatShapedDateTime(epochMs, d.timeZone, d.fmt); } export function ConnectionTestStatus({ @@ -44,6 +61,7 @@ export function ConnectionTestStatus({ nowMs?: number; }) { const now = nowMs ?? Date.now(); + const display: Display = { timeZone: useDisplayTimeZone(), fmt: useChromeDateTimeFormat() }; const mine = useMemo( () => results @@ -75,8 +93,8 @@ export function ConnectionTestStatus({ {m.settings_conn_last_tested()}{" "} - @@ -98,8 +116,8 @@ export function ConnectionTestStatus({ {r.ok ? "✓" : "✗"} - diff --git a/app/components/settings/DateTimeFormatFields.tsx b/app/components/settings/DateTimeFormatFields.tsx new file mode 100644 index 000000000..3d9621fed --- /dev/null +++ b/app/components/settings/DateTimeFormatFields.tsx @@ -0,0 +1,54 @@ +import { Select } from "@core/shared-ui"; +import { useDisplayLocale } from "~/hooks/useSessionContext"; +import { dateFormatOptions, timeFormatOptions } from "~/lib/date-format-options"; + +/** + * #270 — the date-order + clock pair, shared by the company default + * (settings-workspace) and the personal override (settings-profile). + * + * One component rather than two copies because the two surfaces must offer the + * SAME three shapes with the SAME worked examples. A parallel pair drifts, and + * a company whose picker disagrees with its inspectors' picker is worse than + * having no picker: the setting exists to make one vocabulary out of three + * people, and it cannot do that if the two screens name the options differently. + * + * The only real difference is whether "inherit" is offered, which is a prop: + * the tenant value is the bottom of the resolution chain and has nothing to + * inherit from. + */ +export function DateTimeFormatFields({ + dateLabel, + timeLabel, + dateValue, + timeValue, + inheritLabel, +}: { + dateLabel: string; + timeLabel: string; + /** Stored value; `null`/`""` selects the inherit option when one is offered. */ + dateValue: string | null | undefined; + timeValue: string | null | undefined; + /** Omit to render the tenant-level picker, which has no inherit state. */ + inheritLabel?: string; +}) { + // Worked examples ("11 Sep 2026"), so the month word has to be written in the + // language this reader is actually looking at. + const locale = useDisplayLocale(); + const inherit = inheritLabel ? [{ value: "", label: inheritLabel }] : []; + return ( +
    + +
    + ); +} diff --git a/app/components/settings/QboBooksHealth.tsx b/app/components/settings/QboBooksHealth.tsx new file mode 100644 index 000000000..c3c728b23 --- /dev/null +++ b/app/components/settings/QboBooksHealth.tsx @@ -0,0 +1,92 @@ +import { m } from "~/paraglide/messages"; +import { formatCurrency } from "~/lib/format"; +import { useDisplayLocale } from "~/hooks/useSessionContext"; + +export interface QboDiscrepancy { + id: string; + invoiceId: string; + currency: string; + /** What our payment ledger records as received. */ + ledgerCents: number; + /** QuickBooks' implied paid amount (TotalAmt − Balance). */ + qboCents: number; +} + +/** + * The three things on the QuickBooks page that are about the tenant's BOOKS + * rather than about the connection: pushes that failed, figures the two sides + * disagree on, and money we deliberately never send. + * + * A discrepancy is shown with BOTH figures and never as one reconciled number. + * Spec 2026-08-01 payment/deposit flow §6 — our ledger is authoritative for what + * we collected, QuickBooks reports a balance and cannot reconstruct our rows, so + * a human reconciles. Auto-adjusting either side would record money movement + * nobody performed, and showing a single "corrected" figure would hide that the + * question was ever open. + */ +export function QboBooksHealth({ + openErrors, + discrepancies, + heldDepositCount, +}: { + openErrors: number; + discrepancies: QboDiscrepancy[]; + heldDepositCount: number; +}) { + const locale = useDisplayLocale(); + + return ( + <> + {openErrors > 0 && ( +
    +

    + + + + {m.settings_qbo_sync_errors({ count: openErrors })} +

    +

    {m.settings_qbo_sync_errors_desc()}

    +
    + )} + + {/* Surfaced, never auto-corrected. */} + {discrepancies.length > 0 && ( +
    +

    + {m.settings_qbo_discrepancy_heading({ count: discrepancies.length })} +

    +

    {m.settings_qbo_discrepancy_desc()}

    +
      + {discrepancies.map((d) => ( +
    • + {m.settings_qbo_discrepancy_row({ + invoice: d.invoiceId.slice(0, 8), + ours: formatCurrency(d.ledgerCents, { locale, currency: d.currency }), + theirs: formatCurrency(d.qboCents, { locale, currency: d.currency }), + })} +
    • + ))} +
    +
    + )} + + {/* What deliberately does not reach QuickBooks, said where they would look + for it — silence here reads as "everything synced". */} + {heldDepositCount > 0 && ( +
    +

    + {m.settings_qbo_not_synced_heading()} +

    +

    + {m.settings_qbo_not_synced_deposits({ count: heldDepositCount })} +

    +
    + )} + + ); +} diff --git a/app/components/settings/TemplateEditorModal.tsx b/app/components/settings/TemplateEditorModal.tsx new file mode 100644 index 000000000..9b478fec7 --- /dev/null +++ b/app/components/settings/TemplateEditorModal.tsx @@ -0,0 +1,378 @@ +import { useState, useEffect, useRef } from "react"; +import { useFetcher } from "react-router"; +import { Button, Pill, Modal } from "@core/shared-ui"; +import { m } from "~/paraglide/messages"; +import { SUPPORTED_CONTACT_LOCALES } from "../../../server/lib/i18n/contact-locale"; +import { localeLabel } from "~/lib/locales"; + +// ─── Exported pure helper ──────────────────────────────────────────────────── + +/** GSM-ish client segment estimate — mirrors server smsSegmentInfo thresholds. */ +export function smsSegmentsClient(body: string): number { + const len = [...body].length; + if (len === 0) return 0; + // Client keeps the GSM happy-path estimate (server is authoritative on send). + return len <= 160 ? 1 : Math.ceil(len / 153); +} + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface MessageTemplate { + id: string; + tenantId: string; + name: string; + channel: "email" | "sms"; + subject: string | null; + body: string; + variables: string[]; + /** Which language version this row IS. */ + locale: string; + isSeeded: boolean; + createdAt: number; + updatedAt: number; +} + +export /** What the editor modal is currently doing. */ +type EditorTarget = + | { kind: "edit"; template: MessageTemplate } + | { kind: "new"; channel: "email" | "sms"; locale: string; prefill: MessageTemplate | null }; + + +// ─── Template editor modal ──────────────────────────────────────────────────── + +export function TemplateEditorModal({ + target, + onClose, +}: { + target: EditorTarget; + onClose: () => void; +}) { + const template = target.kind === "edit" ? target.template : null; + const prefill = target.kind === "new" ? target.prefill : null; + const channel = target.kind === "edit" ? target.template.channel : target.channel; + const isEmail = channel === "email"; + // The language of the row being written. Fixed for an edit (a version's + // language is what it IS) and for a new version of an existing template + // (which is the whole reason the tenant clicked "Add Spanish"); choosable + // only when starting a template from nothing. + const fixedLocale = template?.locale ?? (prefill ? target.kind === "new" ? target.locale : "en" : null); + const [locale, setLocale] = useState( + template?.locale ?? (target.kind === "new" ? target.locale : "en"), + ); + + const fetcher = useFetcher<{ + ok: boolean; + intent?: string; + preview?: { subject?: string; html?: string; text?: string }; + error?: string; + }>(); + const previewFetcher = useFetcher<{ + ok: boolean; + intent?: string; + preview?: { subject?: string; html?: string; text?: string }; + error?: string; + }>(); + + // A new language version starts from the existing one: translating beats + // retyping, and it keeps the merge variables intact. + const [name, setName] = useState(template?.name ?? prefill?.name ?? ""); + const [subject, setSubject] = useState(template?.subject ?? prefill?.subject ?? ""); + const [body, setBody] = useState(template?.body ?? prefill?.body ?? ""); + const bodyRef = useRef(null); + const [testTo, setTestTo] = useState(""); + const [testSent, setTestSent] = useState(false); + + const segmentCount = !isEmail ? smsSegmentsClient(body) : 0; + + useEffect(() => { + if ( + fetcher.state === "idle" && + fetcher.data?.ok && + fetcher.data.intent !== "preview" && + fetcher.data.intent !== "test-send" + ) { + onClose(); + } + }, [fetcher.state, fetcher.data, onClose]); + + useEffect(() => { + if ( + fetcher.state === "idle" && + fetcher.data?.ok && + fetcher.data.intent === "test-send" + ) { + setTestSent(true); + } + }, [fetcher.state, fetcher.data]); + + function insertVariable(v: string) { + const ta = bodyRef.current; + if (!ta) { + setBody((b) => b + `{{${v}}}`); + return; + } + const start = ta.selectionStart ?? body.length; + const end = ta.selectionEnd ?? body.length; + const snippet = `{{${v}}}`; + const next = body.slice(0, start) + snippet + body.slice(end); + setBody(next); + requestAnimationFrame(() => { + ta.setSelectionRange(start + snippet.length, start + snippet.length); + ta.focus(); + }); + } + + const variables = template?.variables ?? prefill?.variables ?? []; + const isSaving = fetcher.state !== "idle"; + const isTesting = + fetcher.state !== "idle" && fetcher.formData?.get("intent") === "test-send"; + const isPreviewing = previewFetcher.state !== "idle"; + const previewData = previewFetcher.data?.preview; + + return ( + + + + + {template && } + + + + {isEmail && } + + {variables.map((v) => ( + + ))} + + + + } + > +
    + {fetcher.data && !fetcher.data.ok && fetcher.data.intent !== "test-send" && ( +
    + {fetcher.data.error ?? m.settings_error_generic()} +
    + )} + + {/* Language */} +
    + + {fixedLocale ? ( +
    + {localeLabel(fixedLocale)} + + {m.settings_msgtpl_language_locked({ name: name || m.settings_msgtpl_name_placeholder() })} + +
    + ) : ( + + )} +
    + + {/* Name */} +
    + + setName(e.target.value)} + placeholder={m.settings_msgtpl_name_placeholder()} + required + // Versions are matched by (name, channel). Letting the name drift + // here would silently create an unrelated template that no send + // path would ever fall back to. + readOnly={prefill !== null} + className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-input text-[13px] text-ih-fg-1 placeholder:text-ih-fg-4 read-only:text-ih-fg-3" + /> +
    + + {/* Subject (email only) */} + {isEmail && ( +
    + + setSubject(e.target.value)} + placeholder={m.settings_msgtpl_subject_placeholder()} + className="w-full h-9 px-3 rounded-md border border-ih-border bg-ih-bg-input text-[13px] text-ih-fg-1 placeholder:text-ih-fg-4" + /> +
    + )} + + {/* Body */} +
    + + {variables.length > 0 && ( +
    + {m.settings_msgtpl_insert_label()} + {variables.map((v) => ( + + ))} +
    + )} +