Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
351173d
chore: remove orphan routes + dead files (agreement-printable, settin…
important-new Jul 21, 2026
94929dd
feat(team): cancel + resend a pending seat invite (owner/manager)
important-new Jul 21, 2026
8aca51d
feat(team): pending-invite cancel/resend UI with expiry + confirm modal
important-new Jul 21, 2026
0fd5778
feat(contacts): archive-or-delete, referral count, freeze agent email
important-new Jul 21, 2026
68fc9d1
chore(mcp): regenerate OpenAPI snapshot for team invite cancel/resend…
important-new Jul 21, 2026
32b7282
test(contacts): guard archive-referral visibility via the email-fallb…
important-new Jul 21, 2026
c4574b8
feat(contacts): Agents CRM directory (referrals), archive confirm, re…
important-new Jul 21, 2026
b573b10
refactor: remove dormant M20 IdentitySwitcher, keep GDPR account routes
important-new Jul 21, 2026
16047ef
chore(test): purge stale M20 IdentitySwitcher references from fixture…
important-new Jul 21, 2026
9f5de4d
chore: zero out knip dead-code baseline (resolve final 5)
important-new Jul 21, 2026
73c4561
chore(lint): enable react-hooks, type-aware, react/a11y, and boundary…
important-new Jul 21, 2026
58b6517
fix(lint): don't warn-ify jsx-a11y off-by-design rules; content cache…
important-new Jul 21, 2026
81cebe4
fix(team): re-assert status='pending' in cancelInvite DELETE (close T…
important-new Jul 21, 2026
9911908
fix: post-release triage wave (team names, hooks, deep-link, dead key…
important-new Jul 21, 2026
ee18c27
chore(db): squash migrations into a single 0000_baseline
important-new Jul 21, 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
9 changes: 7 additions & 2 deletions app/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { Modal } from "@core/shared-ui";

export function ConfirmDialog({
open, title, message, confirmLabel = "Delete", busy = false, onConfirm, onCancel,
open, title, message, confirmLabel = "Delete", tone = "danger", busy = false, onConfirm, onCancel,
}: {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
tone?: "danger" | "default";
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const confirmClass =
tone === "danger"
? "bg-ih-bad-fg text-white hover:opacity-90"
: "bg-ih-primary text-white hover:opacity-90";
return (
<Modal
open={open}
Expand All @@ -30,7 +35,7 @@ export function ConfirmDialog({
type="button"
onClick={onConfirm}
disabled={busy}
className="px-4 py-2 rounded-md bg-ih-bad-fg text-white text-[13px] font-bold hover:opacity-90 transition-opacity disabled:opacity-50"
className={`px-4 py-2 rounded-md text-[13px] font-bold transition-opacity disabled:opacity-50 ${confirmClass}`}
>
{confirmLabel}
</button>
Expand Down
35 changes: 25 additions & 10 deletions app/components/contacts/AgentsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
import { Card, Pill, EmptyState, Table } from "@core/shared-ui";
import { Card, EmptyState, Table } from "@core/shared-ui";
import { m } from "~/paraglide/messages";
import type { Agent } from "./contacts-helpers";
import type { Contact } from "./contacts-helpers";

export function AgentsTable({ agentList }: { agentList: Agent[] }) {
export function AgentsTable({
agentContacts,
onEdit,
onArchive,
}: {
agentContacts: Contact[];
onEdit: (c: Contact) => void;
onArchive: (c: Contact) => void;
}) {
return (
<Card className="overflow-hidden">
<Table<Agent>
rows={agentList}
<Table<Contact>
rows={agentContacts}
getRowKey={(a) => a.id}
empty={<EmptyState title={m.contacts_agents_empty_title()} />}
empty={<EmptyState title={m.contacts_agents_empty_title()} description={m.contacts_agents_empty_desc()} />}
columns={[
{ label: m.contacts_type_agent(), cell: (a) => <span className="font-medium text-ih-fg-1">{a.name}</span> },
{ label: m.contacts_agents_col_status(), cell: (a) => <Pill tone={a.status === "active" ? "sat" : "monitor"}>{a.status}</Pill> },
{ label: m.contacts_agents_col_linked(), cell: (a) => <span className="text-ih-fg-3">{a.linkedAt || "—"}</span> },
{ label: m.contacts_table_col_name(), cell: (a) => <span className="font-medium text-ih-fg-1">{a.name}</span> },
{ label: m.contacts_field_agency(), cell: (a) => <span className="text-ih-fg-3">{a.agency || "—"}</span> },
{ label: m.contacts_field_email(), cell: (a) => <span className="text-ih-fg-3">{a.email || "—"}</span> },
{ label: m.contacts_field_phone(), cell: (a) => <span className="text-ih-fg-3">{a.phone || "—"}</span> },
{ label: m.contacts_agents_col_referrals(), cell: (a) => <span className="text-ih-fg-1 font-medium tabular-nums">{a.referralCount ?? 0}</span> },
{
label: <span className="sr-only">{m.contacts_table_col_actions()}</span>,
align: "right",
cell: () => <button className="text-ih-bad-fg text-[12px] font-bold hover:underline">{m.contacts_agents_revoke()}</button>,
cell: (a) => (
<>
<button onClick={() => onEdit(a)} className="text-ih-primary text-[12px] font-bold hover:underline mr-3">{m.common_edit()}</button>
<button onClick={() => onArchive(a)} className="text-ih-fg-3 text-[12px] font-bold hover:text-ih-fg-1 hover:underline">{m.contacts_action_archive()}</button>
</>
),
},
]}
/>
Expand Down
11 changes: 8 additions & 3 deletions app/components/contacts/ContactModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,16 @@ export function ContactModal({
defaultValue={contact?.email || ""}
placeholder={m.contacts_modal_email_placeholder()}
aria-invalid={fields.email.errors ? true : undefined}
className="w-full px-3 py-2 rounded-md border border-ih-border bg-ih-bg-card focus:border-ih-primary focus:ring-1 focus:ring-ih-primary outline-none text-sm"
readOnly={isEdit && contact?.type === "agent"}
className={`w-full px-3 py-2 rounded-md border border-ih-border bg-ih-bg-card focus:border-ih-primary focus:ring-1 focus:ring-ih-primary outline-none text-sm ${
isEdit && contact?.type === "agent" ? "opacity-60 cursor-not-allowed" : ""
}`}
/>
{fields.email.errors && (
{isEdit && contact?.type === "agent" ? (
<p className="mt-1 text-xs text-ih-fg-4">{m.contacts_agent_email_locked_hint()}</p>
) : fields.email.errors ? (
<p className="mt-1 text-xs text-ih-bad-fg">{fields.email.errors[0]}</p>
)}
) : null}
</div>
<div>
<label htmlFor={fields.phone.id} className="block text-[10px] font-bold text-ih-fg-4 uppercase tracking-widest mb-1.5">{m.contacts_field_phone()}</label>
Expand Down
19 changes: 6 additions & 13 deletions app/components/contacts/ContactsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import type { useFetcher } from "react-router";
import { Link } from "react-router";
import { Card, Pill, EmptyState, Table } from "@core/shared-ui";
import { m } from "~/paraglide/messages";
import type { Contact } from "./contacts-helpers";

export function ContactsTable({
filtered,
setEditContact,
setModalOpen,
deleteFetcher,
onEdit,
onArchive,
}: {
filtered: Contact[];
setEditContact: (c: Contact | null) => void;
setModalOpen: (open: boolean) => void;
deleteFetcher: ReturnType<typeof useFetcher>;
onEdit: (c: Contact) => void;
onArchive: (c: Contact) => void;
}) {
return (
<Card className="overflow-hidden">
Expand All @@ -40,12 +37,8 @@ export function ContactsTable({
align: "right",
cell: (c) => (
<>
<button onClick={() => { setEditContact(c); setModalOpen(true); }} className="text-ih-primary text-[12px] font-bold hover:underline mr-3">{m.common_edit()}</button>
<deleteFetcher.Form method="post" className="inline">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={c.id} />
<button type="submit" className="text-ih-bad-fg text-[12px] font-bold hover:underline">{m.common_delete()}</button>
</deleteFetcher.Form>
<button onClick={() => onEdit(c)} className="text-ih-primary text-[12px] font-bold hover:underline mr-3">{m.common_edit()}</button>
<button onClick={() => onArchive(c)} className="text-ih-fg-3 text-[12px] font-bold hover:text-ih-fg-1 hover:underline">{m.contacts_action_archive()}</button>
</>
),
},
Expand Down
9 changes: 1 addition & 8 deletions app/components/contacts/contacts-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,7 @@ export interface Contact {
type: string;
agency: string;
inspectionCount?: number;
}

export interface Agent {
id: string;
name: string;
email: string;
status: string;
linkedAt: string;
referralCount?: number;
}

/** Mirrors `RoleProfileSchema` (server/lib/validations/role-profile.schema.ts). */
Expand Down
12 changes: 6 additions & 6 deletions app/components/editor/ItemEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
flattenItemTabs, fragmentBeforeCaret, replaceFragmentBeforeCaret,
} from "../../lib/comment-typeahead";
import { CloneLastButton } from "./CloneLastButton";
import { type DefectFieldsValue } from "./DefectFieldsRow";
import type { DefectFieldsValue } from "./DefectFieldsRow";
import { ItemAttributesPanel } from "./ItemAttributesPanel";
import { RatingButtonRow } from "./RatingButtonRow";
import {
Expand Down Expand Up @@ -339,12 +339,12 @@ export function ItemEditor({
setCustomFormOpen(false);
};

// Build visible tabs for shared TabStrip (only tabs with entries)
const visibleTabs = useMemo(() =>
CANNED_TAB_IDS
// Visible canned tabs (only tabs with entries). A plain derivation, NOT a
// hook: it sits after the `if (!item) return null` early return, so a useMemo
// here would be a conditional hook (rules-of-hooks). The 3-id map is cheap.
const visibleTabs = CANNED_TAB_IDS
.filter((id) => ((tabs[id] || []) as unknown[]).length > 0)
.map((id) => ({ id, label: cannedTabLabel(id), count: getIncludedSet(id).size || undefined })),
[tabs, result]);
.map((id) => ({ id, label: cannedTabLabel(id), count: getIncludedSet(id).size || undefined }));

// Photo count is read in several places (badge, caption, empty-state copy).
const photoCount = ((result.photos as unknown[]) || []).length;
Expand Down
6 changes: 5 additions & 1 deletion app/lib/hub-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { INSPECTION_STATUS, isReportPublished } from '~/lib/status';
import { formatCurrency } from '~/lib/format';
import { m } from '~/paraglide/messages';

/** Pill tone union — kept in sync with packages/shared-ui/src/Pill.tsx. */
/**
* Pill tone union — kept in sync with packages/shared-ui/src/Pill.tsx.
* @public — consumed via an inline `import("~/lib/hub-blocks").PillTone` type
* reference in inspection-hub.tsx, which knip cannot trace (dynamic-import blind spot).
*/
export type PillTone =
| 'sat'
| 'monitor'
Expand Down
2 changes: 1 addition & 1 deletion app/lib/viewer-timezone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { getBrowserTimeZone, TIMEZONE_OPTIONS } from "~/lib/timezones";

/**
* Viewer timezone for public, unauthenticated surfaces that carry no tenant
* (observe / verify / concierge / agreement-printable). These links have no
* (observe / verify / concierge). These links have no
* session context and no tenant slug, so there is no configured zone to anchor
* to — the honest anchor is the viewer's own zone. Mainstream field-service
* tools resolve times to the viewer's browser zone rather than defaulting
Expand Down
9 changes: 0 additions & 9 deletions app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export default [
route("verify", "routes/public/verify-offline.tsx"),
route("v/:token", "routes/public/v.$token.tsx"),
route("observe/inspections/:id", "routes/public/observe.tsx"),
route("concierge/expired", "routes/public/concierge-expired.tsx"),
// Flow A — client redeems the agent-concierge magic link emailed as
// ${APP_BASE_URL}/confirm/<token>. Shows booking details then POSTs the
// confirm and follows the server-chosen redirect (agreement / report).
Expand All @@ -61,10 +60,6 @@ export default [
route("portal/:tenant", "routes/public/portal.tsx"),
route("portal/:tenant/auth", "routes/public/portal-auth.tsx"),
route("portal/:tenant/i/:inspectionId", "routes/public/portal-inspection.tsx"),
route(
"agreements/print/:token",
"routes/public/agreement-printable.tsx",
),
]),
// Standalone pages (own chrome, no sidebar)
route("setup", "routes/setup.tsx"),
Expand All @@ -79,7 +74,6 @@ export default [
),
// Standalone agent pages — no agent-layout chrome
route("agent-invite/:token", "routes/agent/invite-accept.tsx"),
route("agent-invite-expired", "routes/agent/invite-expired.tsx"),
route("agent-signup", "routes/agent/signup.tsx"),
// Spec 3 Task 5 — core dual-mode agent front door (password + magic-link).
route("agent-login", "routes/agent/login.tsx"),
Expand All @@ -95,7 +89,6 @@ export default [
route("resources/repair-items", "routes/resources/repair-items.tsx"),
route("resources/cost-items", "routes/resources/cost-items.tsx"),
route("resources/cost-export", "routes/resources/cost-export.tsx"),
route("resources/identities", "routes/resources/identities.tsx"),
route("resources/inspection-prefs", "routes/resources/inspection-prefs.tsx"),
route("resources/inspection-settings-sheet", "routes/resources/inspection-settings-sheet.tsx"),
route("resources/inspection-media", "routes/resources/inspection-media.tsx"),
Expand Down Expand Up @@ -142,7 +135,6 @@ export default [
route("settings/automations", "routes/settings-automations.tsx"),
route("settings/data", "routes/settings-data.tsx"),
route("settings/compliance", "routes/settings-compliance.tsx"),
route("settings/widget", "routes/settings-widget.tsx"),
route("settings/account", "routes/settings-account.tsx"),
route("settings/advanced", "routes/settings-advanced.tsx"),
route("settings/integrations", "routes/settings-integrations.tsx"),
Expand All @@ -152,7 +144,6 @@ export default [
route("settings/inspection-types", "routes/settings-inspection-types.tsx"),
route("settings/schedule", "routes/settings-schedule.tsx"),
route("settings/booking", "routes/settings-booking.tsx"),
route("settings/catalog/booking", "routes/settings-catalog-booking.tsx"),
route("settings/billing", "routes/settings-billing.tsx"),
route("settings/usage", "routes/settings-usage.tsx"),
route("settings/security", "routes/settings-security.tsx"),
Expand Down
127 changes: 0 additions & 127 deletions app/routes/agent/invite-expired.tsx

This file was deleted.

Loading
Loading