fix(ux): pass-2 audit fixes — admin grouping, Duplicates entry, header tooltips
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m11s
Build & Push Docker Images / build-and-push (push) Failing after 5m45s

Three small but high-leverage fixes from the second audit pass on main:

Admin index (src/app/(dashboard)/[portSlug]/admin/page.tsx):
  - Grouped 21 sections into 7 categories: Access, Configuration, Content,
    Data Quality, Operations, Tenancy, Integrations. Each group has a
    one-line description so first-time admins can orient themselves
    without reading every card.
  - Added the missing Duplicates entry (links to /admin/duplicates from
    the dedup-migration work) under Data Quality.

More sheet (mobile bottom-drawer nav):
  - "Email" -> "Inbox". The page that opens is an email-inbox surface
    (Inbox + Accounts tabs), not a generic email composer. The previous
    label was ambiguous.

Interest detail header (Won / Lost outcome buttons):
  - Added title="Mark as won" / "Close as lost" so the icon-only buttons
    on mobile have a tooltip on long-press / desktop hover.
  - Tightened mobile padding (px-2 vs px-2.5) so the full-text desktop
    labels still fit on sm+ without re-introducing a regression where a
    visible mobile "Won"/"Lost" inline label crowded the right cluster
    enough to push Email/Call/WhatsApp action chips into a vertical
    stack.

Verification: 0 tsc errors, 926/926 vitest passing, lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-03 16:35:32 +02:00
parent d7ec2a8507
commit a792d9a182
3 changed files with 215 additions and 142 deletions

View File

@@ -16,6 +16,7 @@ import {
Tag, Tag,
Upload, Upload,
Users, Users,
UsersRound,
Webhook, Webhook,
} from 'lucide-react'; } from 'lucide-react';
@@ -29,7 +30,17 @@ interface AdminSection {
icon: typeof Settings; icon: typeof Settings;
} }
const SECTIONS: AdminSection[] = [ interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
const GROUPS: AdminGroup[] = [
{
title: 'Access',
description: 'Who can sign in and what they can do once they do.',
sections: [
{ {
href: 'users', href: 'users',
label: 'Users', label: 'Users',
@@ -48,12 +59,12 @@ const SECTIONS: AdminSection[] = [
description: 'Default permission sets and per-port role overrides.', description: 'Default permission sets and per-port role overrides.',
icon: Shield, icon: Shield,
}, },
{ ],
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
}, },
{
title: 'Configuration',
description: 'Branding, integrations, and per-port settings.',
sections: [
{ {
href: 'email', href: 'email',
label: 'Email Settings', label: 'Email Settings',
@@ -90,6 +101,12 @@ const SECTIONS: AdminSection[] = [
description: 'Outgoing webhook subscriptions, secrets, and delivery log.', description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook, icon: Webhook,
}, },
],
},
{
title: 'Content',
description: 'Forms, templates, and labels that users see.',
sections: [
{ {
href: 'forms', href: 'forms',
label: 'Forms', label: 'Forms',
@@ -114,6 +131,36 @@ const SECTIONS: AdminSection[] = [
description: 'Tenant-defined fields for clients, yachts, and reservations.', description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key, icon: Key,
}, },
],
},
{
title: 'Data Quality',
description: 'Cleanup, imports, and the audit trail.',
sections: [
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: UsersRound,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
],
},
{
title: 'Operations',
description: 'Health checks and disaster recovery.',
sections: [
{ {
href: 'reports', href: 'reports',
label: 'Reports', label: 'Reports',
@@ -126,18 +173,18 @@ const SECTIONS: AdminSection[] = [
description: 'BullMQ queue health, throughput, and retry diagnostics.', description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database, icon: Database,
}, },
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{ {
href: 'backup', href: 'backup',
label: 'Backup & Restore', label: 'Backup & Restore',
description: 'Database snapshots and on-demand exports.', description: 'Database snapshots and on-demand exports.',
icon: HardDrive, icon: HardDrive,
}, },
],
},
{
title: 'Tenancy',
description: 'Multi-port and multi-install scaffolding.',
sections: [
{ {
href: 'ports', href: 'ports',
label: 'Ports', label: 'Ports',
@@ -150,12 +197,20 @@ const SECTIONS: AdminSection[] = [
description: 'Initial-setup wizard for fresh ports.', description: 'Initial-setup wizard for fresh ports.',
icon: LayoutDashboard, icon: LayoutDashboard,
}, },
],
},
{
title: 'Integrations',
description: 'Third-party providers wired into the app.',
sections: [
{ {
href: 'ocr', href: 'ocr',
label: 'Receipt OCR', label: 'Receipt OCR',
description: 'Configure the AI provider used by the mobile receipt scanner.', description: 'Configure the AI provider used by the mobile receipt scanner.',
icon: ScrollText, icon: ScrollText,
}, },
],
},
]; ];
export default async function AdminLandingPage({ export default async function AdminLandingPage({
@@ -165,13 +220,21 @@ export default async function AdminLandingPage({
}) { }) {
const { portSlug } = await params; const { portSlug } = await params;
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<PageHeader <PageHeader
title="Administration" title="Administration"
description="Per-port configuration and system administration. Each card below opens a dedicated settings page." description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
/> />
{GROUPS.map((group) => (
<section key={group.title} className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.title}
</h2>
<p className="text-xs text-muted-foreground/80">{group.description}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{SECTIONS.map((s) => { {group.sections.map((s) => {
const Icon = s.icon; const Icon = s.icon;
return ( return (
<Link <Link
@@ -195,6 +258,8 @@ export default async function AdminLandingPage({
); );
})} })}
</div> </div>
</section>
))}
</div> </div>
); );
} }

View File

@@ -339,12 +339,19 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</button> </button>
) : ( ) : (
<> <>
{/* Mobile: icon-only with title tooltip + colored fill carries
the won/lost meaning (green vs rose). Adding a "Won" /
"Lost" text label inline blew out the cluster width and
forced the Email/Call/WhatsApp action-chip row above to
stack vertically — bad trade. From sm up, the full
"Mark won" / "Close as lost" labels read clearly. */}
<button <button
type="button" type="button"
onClick={() => setOutcomeDialog('won')} onClick={() => setOutcomeDialog('won')}
aria-label="Mark as won" aria-label="Mark as won"
title="Mark as won"
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', 'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-2.5',
'border border-emerald-200 bg-emerald-50 text-emerald-700', 'border border-emerald-200 bg-emerald-50 text-emerald-700',
'hover:bg-emerald-100', 'hover:bg-emerald-100',
)} )}
@@ -356,8 +363,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
type="button" type="button"
onClick={() => setOutcomeDialog('lost')} onClick={() => setOutcomeDialog('lost')}
aria-label="Close as lost" aria-label="Close as lost"
title="Close as lost"
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', 'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-2.5',
'border border-rose-200 text-rose-700', 'border border-rose-200 text-rose-700',
'hover:bg-rose-50', 'hover:bg-rose-50',
)} )}

View File

@@ -41,7 +41,7 @@ const MORE_ITEMS: MoreItem[] = [
{ label: 'Companies', icon: Building2, segment: 'companies' }, { label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Invoices', icon: FileText, segment: 'invoices' }, { label: 'Invoices', icon: FileText, segment: 'invoices' },
{ label: 'Expenses', icon: Receipt, segment: 'expenses' }, { label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Email', icon: Mail, segment: 'email' }, { label: 'Inbox', icon: Mail, segment: 'email' },
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' }, { label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
{ label: 'Reports', icon: BarChart3, segment: 'reports' }, { label: 'Reports', icon: BarChart3, segment: 'reports' },
{ label: 'Reminders', icon: Bell, segment: 'reminders' }, { label: 'Reminders', icon: Bell, segment: 'reminders' },