Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
e0a4d69
fix(publish): decouple report delivery from the order lifecycle
important-new Jul 23, 2026
48de222
fix(hub): stop hiding the Report card behind the order lifecycle
important-new Jul 23, 2026
6faf092
fix(complete): stop firing report.published from order completion
important-new Jul 23, 2026
6814b11
feat: let inspectors mark fieldwork complete, from either surface
important-new Jul 23, 2026
3518acc
test(e2e): cover publishing with and without the completion step
important-new Jul 23, 2026
c74d1c8
fix(agent): read defect states by the canonical key so recommendation…
important-new Jul 23, 2026
acfe172
fix(pca): map the produced bucket domain in the Systems Summary sever…
important-new Jul 23, 2026
5884898
fix(report): keep internal_notes and price out of the public report p…
important-new Jul 23, 2026
91ebac9
fix(repair): resolve the actor kind instead of assuming client
important-new Jul 23, 2026
6d8857a
feat(repair): tenant switch for agent access to the repair list (IA-73)
important-new Jul 23, 2026
6a68ce5
feat(portal): revoke report access on removal, expire links on unpubl…
important-new Jul 23, 2026
9fab550
fix(command-palette): mount it workspace-wide so Cmd/Ctrl+K works eve…
important-new Jul 23, 2026
e0368c9
fix(sidebar): wire the search button to open the command palette (IA-38)
important-new Jul 23, 2026
55298a3
fix(marketplace): wire the Install button to actually import the temp…
important-new Jul 23, 2026
6d906c7
feat(reports): surface report version history + fix the diff page (IA…
important-new Jul 24, 2026
54aaca1
refactor(reports): unify the rating-axis value domains (IA-43)
important-new Jul 24, 2026
076b96a
fix(repair-builder): separate the category and severity sort axes (IA…
important-new Jul 24, 2026
1ac5121
fix(terminology): converge repair/category naming + kill a dead link …
important-new Jul 24, 2026
b6db657
refactor(agent): rename agent-recommendations → agent-repair-items (I…
important-new Jul 24, 2026
d71cd3f
fix(agent): stop the agent feed dropping custom defects + tenant cate…
important-new Jul 24, 2026
df5d6b4
fix(agent): return the magic-login agent to the report they were view…
important-new Jul 24, 2026
aab556b
fix(reports,editor): category-driven defect filter + tenant custom-de…
important-new Jul 24, 2026
ebe7ca3
feat(repair): carry defect title/location/category to the share page …
important-new Jul 24, 2026
861475d
chore(agent,migrations): commit orphaned tails of IA-54 and IA-55
important-new Jul 24, 2026
a024c44
feat(reports): render defect trade + timeframe independently (M-C: IA…
important-new Jul 24, 2026
72e0a44
fix(portal): retire the orphaned report-gate page into the Hub overvi…
important-new Jul 24, 2026
dd33ea3
feat(agreements): deliver the /verify link to every signer + the Hub …
important-new Jul 24, 2026
f51c42c
feat(agreements,repair): expire + revoke the content-bearing public t…
important-new Jul 24, 2026
de36f1a
feat(agent): group the referral dashboard by property and drop dead c…
important-new Jul 24, 2026
1c8346e
feat(workspace): full command palette, per-inspector metrics, and cha…
important-new Jul 24, 2026
ecae860
feat(automation): distinguish report amendments from first publish (M…
important-new Jul 24, 2026
6c10dd5
feat(portal): retire the orphaned standalone live-progress page (M-E:…
important-new Jul 24, 2026
9c5d186
feat(lint): gate bare status literals; move both anti-drift gates to …
important-new Jul 24, 2026
04bb7a3
fix(metrics): read the server's `monthly` series; drop the never-wire…
important-new Jul 24, 2026
7370be4
refactor(booking): retire the per-inspector booking URL forms (N: IA-77)
important-new Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions app/components/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <CommandPalette open onOpenChange={() => {}} /> },
{ path: '/resources/recent-inspections', loader: () => ({ inspections: [] }) },
]);
return render(<Stub initialEntries={['/']} />);
}

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/<tenant>/<slug>` path form.
function renderPaletteAsInspector() {
const Stub = createRoutesStub([
{
id: 'routes/auth-layout',
path: '/',
Component: () => <CommandPalette open onOpenChange={() => {}} />,
loader: () => ({
context: {
branding: {
tenantSlug: 'acme',
currentUserSlug: 'jane',
bookingHost: 'app.example.com',
},
},
}),
},
{ path: '/resources/recent-inspections', loader: () => ({ inspections: [] }) },
]);
return render(<Stub initialEntries={['/']} />);
}

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();
});
58 changes: 49 additions & 9 deletions app/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -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 */
/* ------------------------------------------------------------------ */
Expand All @@ -21,6 +33,14 @@
/* 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[] {
Expand Down Expand Up @@ -50,8 +70,10 @@
{ 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" },
Expand Down Expand Up @@ -135,8 +157,20 @@
/* 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<HTMLInputElement>(null);
Expand All @@ -147,11 +181,14 @@
// F6 — Build booking link action dynamically from session context
const bookingActions = useMemo(() => {
const actions: PaletteItem[] = [];
const slug = sessionCtx?.branding?.currentUserSlug;

Check warning on line 184 in app/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / verify

Unnecessary optional chain on a non-nullish value
const host = sessionCtx?.branding?.bookingHost;

Check warning on line 185 in app/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / verify

Unnecessary optional chain on a non-nullish value
const tenant = sessionCtx?.branding?.tenantSlug;

Check warning on line 186 in app/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / verify

Unnecessary optional chain on a non-nullish value
// 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(),
Expand All @@ -171,22 +208,22 @@
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
setOpen(!openRef.current);
setQuery("");
setActiveIdx(0);
}
if (e.key === "Escape") setOpen(false);
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
}, [setOpen]);

// Focus input when opened; lazy-load recent inspections via BFF resource route
useEffect(() => {
if (open) {
inputRef.current?.focus();
if (recentsFetcher.state === "idle" && !recentsFetcher.data) {
recentsFetcher.load("/resources/recent-inspections");

Check warning on line 226 in app/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / verify

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
}
}
}, [open, recentsFetcher]);
Expand All @@ -205,15 +242,15 @@
} 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) });

Check warning on line 246 in app/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / verify

'insp.id || ""' will use Object's default stringification format ('[object Object]') when stringified
return {
id: `ri-${i}`,
label: addr as string,
group: m.command_palette_group_recent(),
icon: "clip",
hint: (insp.status as string) || "",
to: `/inspections/${insp.id}`,

Check warning on line 253 in app/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / verify

Invalid type "unknown" of template literal expression
};
});
sources = [...getPages(), ...recents, ...getSettings(), ...dynamicQuickActions];
Expand All @@ -225,14 +262,17 @@
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.map((x) => x.item);
}, [query, recentsFetcher.data]);

Check warning on line 265 in app/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / verify

React Hook useMemo has a missing dependency: 'bookingActions'. Either include it or remove the dependency array

// 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<string, PaletteItem[]>();
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;
Expand All @@ -251,11 +291,11 @@
if (action.id === "qa-new-inspection" && onNewInspection) {
onNewInspection();
} else if (action.to) {
navigate(action.to);

Check warning on line 294 in app/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / verify

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
} else if (action.onSelect) {
action.onSelect();
}
}, [navigate, onNewInspection]);

Check warning on line 298 in app/components/CommandPalette.tsx

View workflow job for this annotation

GitHub Actions / verify

React Hook useCallback has a missing dependency: 'setOpen'. Either include it or remove the dependency array

function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
Expand Down
3 changes: 3 additions & 0 deletions app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -22,6 +23,7 @@ export function Sidebar() {
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
const ctx = useSessionContext();
const { openPalette } = useCommandPalette();

const companyName = ctx?.branding?.companyName || "OpenInspection";
const logoUrl = ctx?.branding?.logoUrl || "/logo.svg";
Expand Down Expand Up @@ -70,6 +72,7 @@ export function Sidebar() {
<div className="px-2 pt-2.5 pb-1">
<button
type="button"
onClick={openPalette}
className="w-full flex items-center gap-2 px-[10px] py-[7px] rounded-ih-button bg-ih-bg-muted hover:bg-ih-bg-muted/80 text-ih-fg-4 transition-all border border-ih-border text-[12px]"
aria-label={m.nav_action_command_palette()}
>
Expand Down
37 changes: 37 additions & 0 deletions app/components/audit/EntityAuditTrail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { createRoutesStub } from 'react-router';
import { EntityAuditTrail, type AuditEntry } from './EntityAuditTrail';

// IA-64 — the disclosure must stay collapsed until opened, then lazy-load the
// entity's audit trail and surface "Last edited by X" plus the full history.
function renderTrail(entries: AuditEntry[]) {
const Stub = createRoutesStub([
{ path: '/', Component: () => <EntityAuditTrail entityId="tmpl-1" /> },
{ path: '/resources/entity-audit', loader: () => ({ entries }) },
]);
return render(<Stub initialEntries={['/']} />);
}

const ENTRIES: AuditEntry[] = [
{ id: 'a2', action: 'template.update', actorId: 'u1', actorName: 'Ed Editor', createdAt: 1_700_000_200_000 },
{ id: 'a1', action: 'template.create', actorId: 'u1', actorName: 'Ed Editor', createdAt: 1_700_000_100_000 },
];

test('history is collapsed by default and reveals attribution on open', async () => {
renderTrail(ENTRIES);
// Collapsed: the trail body is not rendered yet.
expect(screen.queryByText(/Last edited by/)).toBeNull();

fireEvent.click(screen.getByRole('button', { name: /history/i }));

// Latest actor surfaces as the headline, and both history rows render.
expect(await screen.findByText(/Last edited by Ed Editor/)).toBeTruthy();
expect(screen.getByText('Created')).toBeTruthy();
expect(screen.getByText('Updated')).toBeTruthy();
});

test('shows an empty state when the entity has no recorded changes', async () => {
renderTrail([]);
fireEvent.click(screen.getByRole('button', { name: /history/i }));
expect(await screen.findByText('No recorded changes yet.')).toBeTruthy();
});
85 changes: 85 additions & 0 deletions app/components/audit/EntityAuditTrail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useState } from "react";
import { useFetcher } from "react-router";
import { formatInspectionDateTime } from "~/lib/format-date";
import { m } from "~/paraglide/messages";

// IA-64 — the read side of change traceability. Templates and comments are
// company assets whose edits were already audited but never surfaced. This
// disclosure lazy-loads an entity's audit trail on first open (no N+1 on the
// parent list) and shows "Last edited by X" plus the full history.
export interface AuditEntry {
id: string;
action: string;
actorId: string | null;
actorName: string | null;
createdAt: number;
}

function actionLabel(action: string): string {
if (action.endsWith(".create") || action.endsWith(".created")) return m.audit_action_created();
if (action.endsWith(".update") || action.endsWith(".updated")) return m.audit_action_updated();
if (action.endsWith(".delete") || action.endsWith(".deleted")) return m.audit_action_deleted();
return m.audit_action_other();
}

function when(createdAt: number, timeZone?: string): string {
return formatInspectionDateTime(new Date(createdAt).toISOString(), undefined, timeZone);
}

export function EntityAuditTrail({ entityId, timeZone }: { entityId: string; timeZone?: string }) {
const [open, setOpen] = useState(false);
const fetcher = useFetcher<{ entries: AuditEntry[] }>();

function toggle() {
const next = !open;
setOpen(next);
if (next && fetcher.state === "idle" && !fetcher.data) {
fetcher.load(`/resources/entity-audit?entityId=${encodeURIComponent(entityId)}`);
}
}

const entries = fetcher.data?.entries ?? [];
const latest = entries[0];

return (
<div className="mt-1">
<button
type="button"
onClick={toggle}
aria-expanded={open}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-ih-fg-4 hover:text-ih-fg-2 transition-colors"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{open ? m.audit_trail_hide() : m.audit_trail_history()}
</button>
{open && (
<div className="mt-1.5 rounded-md border border-ih-border bg-ih-bg-app/40 px-3 py-2">
{fetcher.state === "loading" ? (
<p className="text-[11px] text-ih-fg-4">{m.audit_trail_loading()}</p>
) : entries.length === 0 ? (
<p className="text-[11px] text-ih-fg-4">{m.audit_trail_empty()}</p>
) : (
<>
<p className="text-[11px] font-semibold text-ih-fg-2">
{m.audit_trail_last_edited({ name: latest.actorName || m.audit_trail_unknown_actor() })}
<span className="text-ih-fg-4 font-normal"> · {when(latest.createdAt, timeZone)}</span>
</p>
<ul className="mt-1.5 space-y-1 border-t border-ih-border pt-1.5">
{entries.map((e) => (
<li key={e.id} className="flex items-center justify-between gap-3 text-[11px] text-ih-fg-3">
<span>
<span className="font-medium text-ih-fg-2">{actionLabel(e.action)}</span> · {e.actorName || m.audit_trail_unknown_actor()}
</span>
<span className="text-ih-fg-4 shrink-0">{when(e.createdAt, timeZone)}</span>
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
);
}
4 changes: 4 additions & 0 deletions app/components/editor/CannedCommentTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -144,6 +146,7 @@ export function CannedCommentTabs({
customTitle,
customComment,
customCategory,
customCategories,
saveToLibrary,
showSaveToLibrary,
onCustomTitleChange,
Expand Down Expand Up @@ -319,6 +322,7 @@ export function CannedCommentTabs({
title={customTitle}
comment={customComment}
category={customCategory}
categories={customCategories}
saveToLibrary={saveToLibrary}
showSaveToLibrary={showSaveToLibrary}
onTitleChange={onCustomTitleChange}
Expand Down
Loading
Loading