diff --git a/app/components/CommandPalette.test.tsx b/app/components/CommandPalette.test.tsx new file mode 100644 index 000000000..7e964f188 --- /dev/null +++ b/app/components/CommandPalette.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { createRoutesStub } from 'react-router'; +import { CommandPalette } from './CommandPalette'; + +// IA-50 — the per-group render cap of 8 silently hid 6 of the Settings +// destinations whenever the palette was browsed without a filter word. The cap +// is gone; every static navigation destination must be reachable both by +// browsing and by typing a keyword. +function renderPalette() { + const Stub = createRoutesStub([ + { path: '/', Component: () => {}} /> }, + { path: '/resources/recent-inspections', loader: () => ({ inspections: [] }) }, + ]); + return render(); +} + +test('browsing with no filter word renders every Settings destination, not just the first 8', async () => { + renderPalette(); + // These four all sort past the old cap-of-8 in the Settings group, so under + // the old truncation they never rendered without a query. + expect(await screen.findByText('Settings - QuickBooks')).toBeTruthy(); + expect(screen.getByText('Settings - Payments')).toBeTruthy(); + expect(screen.getByText('Settings - AI')).toBeTruthy(); + expect(screen.getByText('Settings - Data Import / Export')).toBeTruthy(); +}); + +test('the newly added static entries are reachable by keyword', async () => { + renderPalette(); + const input = await screen.findByPlaceholderText(/search/i); + + fireEvent.change(input, { target: { value: 'QuickBooks' } }); + expect(screen.getByText('Settings - QuickBooks')).toBeTruthy(); + + fireEvent.change(input, { target: { value: 'Email Templates' } }); + expect(screen.getByText('Settings - Email Templates')).toBeTruthy(); +}); + +// Per-inspector booking deep links are retired: an inspector shares the +// company booking page, and the server auto-assigns. The palette's +// copy-booking-link action must offer that company URL, not the removed +// `/book//` path form. +function renderPaletteAsInspector() { + const Stub = createRoutesStub([ + { + id: 'routes/auth-layout', + path: '/', + Component: () => {}} />, + loader: () => ({ + context: { + branding: { + tenantSlug: 'acme', + currentUserSlug: 'jane', + bookingHost: 'app.example.com', + }, + }, + }), + }, + { path: '/resources/recent-inspections', loader: () => ({ inspections: [] }) }, + ]); + return render(); +} + +test('copy-booking-link offers the company booking URL, not the retired per-inspector path', async () => { + renderPaletteAsInspector(); + expect(await screen.findByText('https://app.example.com/book/acme')).toBeTruthy(); + expect(screen.queryByText('https://app.example.com/book/acme/jane')).toBeNull(); +}); diff --git a/app/components/CommandPalette.tsx b/app/components/CommandPalette.tsx index 7cd4935f7..797a70b68 100644 --- a/app/components/CommandPalette.tsx +++ b/app/components/CommandPalette.tsx @@ -1,8 +1,20 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from "react"; import { useNavigate, useFetcher } from "react-router"; import { useSessionContext } from "~/hooks/useSessionContext"; import { m } from "~/paraglide/messages"; +/** + * Lets any workspace surface (the sidebar search button, MobileHeader) open the + * command palette. The provider (auth-layout) owns the open state; consumers + * call `openPalette()`. Default is a no-op so a stray consumer outside the + * provider fails silently rather than throwing. + */ +const CommandPaletteContext = createContext<{ openPalette: () => void }>({ openPalette: () => {} }); +export function useCommandPalette() { + return useContext(CommandPaletteContext); +} +export const CommandPaletteProvider = CommandPaletteContext.Provider; + /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ @@ -21,6 +33,14 @@ interface PaletteItem { /* Static sources */ /* ------------------------------------------------------------------ */ +// Recents is the one unbounded group (a busy workspace has hundreds of +// inspections), so it is capped at its source. The static navigation groups +// (Pages, Settings) are bounded lists and must render in full — see the +// `groups` memo, which deliberately does NOT re-truncate per group (#IA-50: +// the old blanket `< 8` cap silently hid 6 of the 14 Settings destinations +// whenever the palette was browsed without a filter word). +const RECENTS_CAP = 8; + // Built as thunks (not module-level consts) so the Paraglide `m.*()` labels // resolve inside the per-request locale scope instead of freezing at import. function getPages(): PaletteItem[] { @@ -50,8 +70,10 @@ function getSettings(): PaletteItem[] { { id: "s-theme", label: m.command_palette_settings_theme(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/workspace" }, { id: "s-services", label: m.command_palette_settings_services(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/services" }, { id: "s-email", label: m.command_palette_settings_email(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/communication" }, + { id: "s-email-templates", label: m.command_palette_settings_email_templates(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/communication/templates" }, { id: "s-automations", label: m.command_palette_settings_automations(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/automations" }, { id: "s-integrations", label: m.command_palette_settings_integrations(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/integrations" }, + { id: "s-qbo", label: m.command_palette_settings_qbo(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/integrations/qbo" }, { id: "s-password", label: m.command_palette_settings_password(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/security" }, { id: "s-2fa", label: m.command_palette_settings_2fa(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/security" }, { id: "s-account", label: m.command_palette_settings_account(), group: m.command_palette_group_settings(), icon: "gear", to: "/settings/security" }, @@ -135,8 +157,20 @@ function PaletteIcon({ type }: { type: string }) { /* Component */ /* ------------------------------------------------------------------ */ -export function CommandPalette({ onNewInspection }: { onNewInspection?: () => void }) { - const [open, setOpen] = useState(false); +export function CommandPalette({ + open, + onOpenChange, + onNewInspection, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onNewInspection?: () => void; +}) { + const setOpen = onOpenChange; + // The Cmd/Ctrl+K listener registers once (empty deps) but must toggle the + // CURRENT open state — read it through a ref so the closure never goes stale. + const openRef = useRef(open); + openRef.current = open; const [query, setQuery] = useState(""); const [activeIdx, setActiveIdx] = useState(0); const inputRef = useRef(null); @@ -150,8 +184,11 @@ export function CommandPalette({ onNewInspection }: { onNewInspection?: () => vo const slug = sessionCtx?.branding?.currentUserSlug; const host = sessionCtx?.branding?.bookingHost; const tenant = sessionCtx?.branding?.tenantSlug; + // Per-inspector booking deep links are retired: bookings go to the + // company page and the server auto-assigns. `slug` still gates the action + // to bookable inspectors, but the copied link is the company URL. if (slug && host && tenant) { - const bookingUrl = `https://${host}/book/${tenant}/${slug}`; + const bookingUrl = `https://${host}/book/${tenant}`; actions.push({ id: "qa-copy-booking-link", label: m.command_palette_action_copy_booking_link(), @@ -171,7 +208,7 @@ export function CommandPalette({ onNewInspection }: { onNewInspection?: () => vo function handleKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); - setOpen((prev) => !prev); + setOpen(!openRef.current); setQuery(""); setActiveIdx(0); } @@ -179,7 +216,7 @@ export function CommandPalette({ onNewInspection }: { onNewInspection?: () => vo } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, []); + }, [setOpen]); // Focus input when opened; lazy-load recent inspections via BFF resource route useEffect(() => { @@ -205,7 +242,7 @@ export function CommandPalette({ onNewInspection }: { onNewInspection?: () => vo } else if (isPeople) { sources = []; // contacts would need a search endpoint } else { - const recents: PaletteItem[] = (recentsFetcher.data?.inspections ?? []).map((insp, i) => { + const recents: PaletteItem[] = (recentsFetcher.data?.inspections ?? []).slice(0, RECENTS_CAP).map((insp, i) => { const addr = [insp.address1, insp.city, insp.state].filter(Boolean).join(", ") || m.command_palette_recent_fallback({ id: String(insp.id || "").slice(0, 6) }); return { id: `ri-${i}`, @@ -227,12 +264,15 @@ export function CommandPalette({ onNewInspection }: { onNewInspection?: () => vo .map((x) => x.item); }, [query, recentsFetcher.data]); - // Group the filtered results + // Group the filtered results. No per-group truncation: every source group is + // already bounded (Pages/Settings are fixed lists; Recents is sliced to + // RECENTS_CAP at its source; quick actions are few). A blanket cap here only + // hid reachable-nowhere-else destinations (#IA-50). const groups = useMemo(() => { const map = new Map(); for (const a of allItems) { const list = map.get(a.group) || []; - if (list.length < 8) list.push(a); + list.push(a); map.set(a.group, list); } return map; diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 50f6bf243..6ca276fbe 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { IC, WORKSPACE_ITEMS } from "~/components/sidebar/nav-items"; import { SidebarGroup } from "~/components/sidebar/SidebarGroup"; import { UserMenuPopover } from "~/components/sidebar/UserMenuPopover"; import { MobileHeader } from "~/components/sidebar/MobileHeader"; +import { useCommandPalette } from "~/components/CommandPalette"; import { Avatar } from "@core/shared-ui"; import { m } from "~/paraglide/messages"; @@ -22,6 +23,7 @@ export function Sidebar() { const [userMenuOpen, setUserMenuOpen] = useState(false); const userMenuRef = useRef(null); const ctx = useSessionContext(); + const { openPalette } = useCommandPalette(); const companyName = ctx?.branding?.companyName || "OpenInspection"; const logoUrl = ctx?.branding?.logoUrl || "/logo.svg"; @@ -70,6 +72,7 @@ export function Sidebar() {
+ {open && ( +
+ {fetcher.state === "loading" ? ( +

{m.audit_trail_loading()}

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

{m.audit_trail_empty()}

+ ) : ( + <> +

+ {m.audit_trail_last_edited({ name: latest.actorName || m.audit_trail_unknown_actor() })} + · {when(latest.createdAt, timeZone)} +

+
    + {entries.map((e) => ( +
  • + + {actionLabel(e.action)} · {e.actorName || m.audit_trail_unknown_actor()} + + {when(e.createdAt, timeZone)} +
  • + ))} +
+ + )} +
+ )} +
+ ); +} diff --git a/app/components/editor/CannedCommentTabs.tsx b/app/components/editor/CannedCommentTabs.tsx index b3a7e75c8..d5a81a326 100644 --- a/app/components/editor/CannedCommentTabs.tsx +++ b/app/components/editor/CannedCommentTabs.tsx @@ -100,6 +100,8 @@ export interface CannedCommentTabsProps { customTitle: string; customComment: string; customCategory: CustomDefectCategory; + /** IA-59 — tenant defect categories offered in the custom-defect dropdown. */ + customCategories?: Array<{ id: string; name: string }>; saveToLibrary: boolean; showSaveToLibrary: boolean; onCustomTitleChange: (value: string) => void; @@ -144,6 +146,7 @@ export function CannedCommentTabs({ customTitle, customComment, customCategory, + customCategories, saveToLibrary, showSaveToLibrary, onCustomTitleChange, @@ -319,6 +322,7 @@ export function CannedCommentTabs({ title={customTitle} comment={customComment} category={customCategory} + categories={customCategories} saveToLibrary={saveToLibrary} showSaveToLibrary={showSaveToLibrary} onTitleChange={onCustomTitleChange} diff --git a/app/components/editor/CustomDefectForm.tsx b/app/components/editor/CustomDefectForm.tsx index 0d87a901c..c83c9a6f4 100644 --- a/app/components/editor/CustomDefectForm.tsx +++ b/app/components/editor/CustomDefectForm.tsx @@ -1,5 +1,5 @@ import { Button } from "@core/shared-ui"; -import type { CustomDefectCategory } from "../../lib/custom-defects"; +import { BUILT_IN_DEFECT_CATEGORIES, type CustomDefectCategory } from "../../lib/custom-defects"; import { m } from "~/paraglide/messages"; export interface CustomDefectFormProps { @@ -9,6 +9,9 @@ export interface CustomDefectFormProps { saveToLibrary: boolean; /** When set, renders the "Save to my library" checkbox (Track H B-20 回流). */ showSaveToLibrary: boolean; + /** IA-59 — the tenant's configured defect categories, offered alongside the + * three built-in seeds so field-added defects can use a custom category. */ + categories?: Array<{ id: string; name: string }>; onTitleChange: (value: string) => void; onCommentChange: (value: string) => void; onCategoryChange: (value: CustomDefectCategory) => void; @@ -24,6 +27,7 @@ export function CustomDefectForm({ category, saveToLibrary, showSaveToLibrary, + categories, onTitleChange, onCommentChange, onCategoryChange, @@ -58,6 +62,13 @@ export function CustomDefectForm({ + {/* IA-59 — tenant's configured categories, minus the three built-ins + (matched by name, case-insensitive) so nothing shows up twice. */} + {(categories ?? []) + .filter((c) => !BUILT_IN_DEFECT_CATEGORIES.includes(c.name.trim().toLowerCase() as (typeof BUILT_IN_DEFECT_CATEGORIES)[number])) + .map((c) => ( + + ))} {/* Track H (B-20 回流) — default OFF so one-off findings don't pollute the library */} {showSaveToLibrary && ( diff --git a/app/components/editor/EditorHeader.tsx b/app/components/editor/EditorHeader.tsx index 6c9a04e6f..e520117e2 100644 --- a/app/components/editor/EditorHeader.tsx +++ b/app/components/editor/EditorHeader.tsx @@ -18,6 +18,10 @@ export interface EditorHeaderProps { setSignModalOpen: (open: boolean) => void; /** Publish button click handler. */ handlePublishClick: () => void; + /** Marks the on-site work complete (advisory order-lifecycle move). */ + handleFinishFieldwork: () => void; + /** Whether the finish-fieldwork request is in flight. */ + finishingFieldwork: boolean; /** #181 — whether collab (and thus version history) is available. */ collabEditing?: boolean; /** Opens the version-history panel. */ @@ -44,6 +48,8 @@ export function EditorHeader({ tenantSlug, setSignModalOpen, handlePublishClick, + handleFinishFieldwork, + finishingFieldwork, collabEditing, onOpenVersionHistory, perUnitControls, @@ -296,6 +302,22 @@ export function EditorHeader({ {m.editor_header_sign()} + {/* Finish fieldwork — advisory order-lifecycle move, shown until the + on-site work is marked complete. Publishing does not require it. A more + central action than Sign (xl-only), so it appears from lg up. */} + {(state.inspection.status as string) !== "completed" && ( + + )} + {/* Publish button */} + + + + ) : ( <> + ) } >

@@ -45,6 +66,11 @@ export function PublishModal({ open, progress, status, publishError, isSubmittin

{m.editor_publish_stat_completion()}{progress.pct}%
{m.editor_publish_stat_status()}{status}
+ {notCompleted && ( +

+ {m.editor_publish_not_completed_prompt()} +

+ )} {publishError && (
{publishError} diff --git a/app/components/editor/canned-comment-tabs-chips.test.ts b/app/components/editor/canned-comment-tabs-chips.test.ts index 9627da5c0..070e2837d 100644 --- a/app/components/editor/canned-comment-tabs-chips.test.ts +++ b/app/components/editor/canned-comment-tabs-chips.test.ts @@ -59,7 +59,7 @@ describe("CannedCommentTabs category pills", () => { const out = html([]); expect(out).toContain("bg-ih-bg-muted"); expect(out).toContain(">maintenance<"); - // custom "custom" badge still present - expect(out).toContain(">custom<"); + // IA-62 — the inspector-added badge (formerly "custom") still present + expect(out).toContain(">inspector-added<"); }); }); diff --git a/app/components/editor/canned-comment-tabs-rows.test.ts b/app/components/editor/canned-comment-tabs-rows.test.ts index 853b38f8a..51b255570 100644 --- a/app/components/editor/canned-comment-tabs-rows.test.ts +++ b/app/components/editor/canned-comment-tabs-rows.test.ts @@ -70,11 +70,11 @@ describe("CannedCommentTabs rows (behavior-preserving swap)", () => { expect((included.match(/data-testid="photo-chip"/g) || []).length).toBe(2); }); - it("renders the custom row with its category chip, custom badge, and photo chip", () => { + it("renders the custom row with its category chip, inspector-added badge, and photo chip", () => { const out = html([]); expect(out).toContain("Gutter loose"); expect(out).toContain(">maintenance<"); - expect(out).toContain(">custom<"); + expect(out).toContain(">inspector-added<"); // custom defect is included:true => its photo chip mounts expect(out).toContain('data-testid="photo-chip"'); }); diff --git a/app/components/inspection-hub/PublishReportModal.tsx b/app/components/inspection-hub/PublishReportModal.tsx index bb55cb721..a5513bc7f 100644 --- a/app/components/inspection-hub/PublishReportModal.tsx +++ b/app/components/inspection-hub/PublishReportModal.tsx @@ -1,4 +1,4 @@ -import { useFetcher } from "react-router"; +import type { useFetcher } from "react-router"; import { Modal } from "@core/shared-ui"; import type { action } from "~/routes/inspection-hub"; import { m } from "~/paraglide/messages"; @@ -13,6 +13,7 @@ export function PublishReportModal({ open, agreementRequired, paymentRequired, + isAmendment, fetcher, submitting, error, @@ -21,6 +22,8 @@ export function PublishReportModal({ open: boolean; agreementRequired: boolean; paymentRequired: boolean; + /** IA-40 — this publish creates versionNumber > 1; ask what changed. */ + isAmendment: boolean; fetcher: ReturnType>; submitting: boolean; error: string | undefined; @@ -55,7 +58,11 @@ export function PublishReportModal({ {/* No theme picker — rides the editor's effective default (server 'modern'); the action sends theme:"modern" explicitly. */} - + + {isAmendment && ( +