fix(audit-wave-9): adopt StatusPill for berth + user status badges

- Extend StatusPill with berth (available/under_offer/sold) and user
  (enabled/disabled) variants so every "this thing is in state X" pill
  shares one primitive and palette.
- Swap berth-card, berth-detail-header, berth-columns from ad-hoc
  bg-green-100 / bg-yellow-100 / bg-red-100 Tailwind tuples to
  <StatusPill status="...">.
- Swap UserList Active/Disabled <Badge> and user-card Inactive pill to
  StatusPill; Super-Admin chip kept as a domain-specific accent (violet).

Closes ui/ux M1+M2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 11:54:13 +02:00
parent 4233aa3ac3
commit a49ee1c347
7 changed files with 67 additions and 70 deletions

View File

@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { StatusPill } from '@/components/ui/status-pill';
import { import {
ListCard, ListCard,
ListCardAvatar, ListCardAvatar,
@@ -169,13 +170,9 @@ export function UserCard({
{/* Status + super-admin pills */} {/* Status + super-admin pills */}
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
{!user.isActive ? ( {!user.isActive ? <StatusPill status="disabled">Disabled</StatusPill> : null}
<span className="inline-flex items-center rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-700">
Inactive
</span>
) : null}
{user.isSuperAdmin ? ( {user.isSuperAdmin ? (
<span className="inline-flex items-center rounded-full bg-violet-100 px-2 py-0.5 text-xs font-medium text-violet-700"> <span className="inline-flex items-center rounded-md border border-violet-200 bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-700">
Super Admin Super Admin
</span> </span>
) : null} ) : null}

View File

@@ -11,6 +11,7 @@ import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { StatusPill } from '@/components/ui/status-pill';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants'; import { formatRole } from '@/lib/constants';
import { UserCard } from './user-card'; import { UserCard } from './user-card';
@@ -98,15 +99,15 @@ export function UserList() {
header: 'Status', header: 'Status',
cell: ({ row }) => cell: ({ row }) =>
row.original.isActive ? ( row.original.isActive ? (
<Badge variant="default" className="bg-green-600"> <StatusPill status="enabled">
<ShieldCheck className="mr-1 h-3 w-3" /> <ShieldCheck className="h-3 w-3" aria-hidden />
Active Active
</Badge> </StatusPill>
) : ( ) : (
<Badge variant="destructive"> <StatusPill status="disabled">
<ShieldOff className="mr-1 h-3 w-3" /> <ShieldOff className="h-3 w-3" aria-hidden />
Disabled Disabled
</Badge> </StatusPill>
), ),
}, },
{ {

View File

@@ -12,23 +12,23 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card'; import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { cn } from '@/lib/utils'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { formatCurrency } from '@/lib/utils/currency'; import { formatCurrency } from '@/lib/utils/currency';
import type { BerthRow } from './berth-columns'; import type { BerthRow } from './berth-columns';
import { mooringLetterDot } from './mooring-letter-tone'; import { mooringLetterDot } from './mooring-letter-tone';
const STATUS_VARIANTS: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-200',
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-200',
sold: 'bg-red-100 text-red-800 border-red-200',
};
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
available: 'Available', available: 'Available',
under_offer: 'Under Offer', under_offer: 'Under Offer',
sold: 'Sold', sold: 'Sold',
}; };
const BERTH_STATUS_PILL: Record<string, StatusPillStatus> = {
available: 'available',
under_offer: 'under_offer',
sold: 'sold',
};
interface BerthCardProps { interface BerthCardProps {
berth: BerthRow; berth: BerthRow;
} }
@@ -39,8 +39,7 @@ export function BerthCard({ berth }: BerthCardProps) {
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const statusLabel = STATUS_LABELS[berth.status] ?? berth.status; const statusLabel = STATUS_LABELS[berth.status] ?? berth.status;
const statusColor = const statusPill = BERTH_STATUS_PILL[berth.status] ?? 'pending';
STATUS_VARIANTS[berth.status] ?? 'bg-muted text-muted-foreground border-muted';
// Accent stripe groups visually by dock (A-row, B-row, ...). Status is // Accent stripe groups visually by dock (A-row, B-row, ...). Status is
// already conveyed by the pill below, so the stripe is dock-keyed. // already conveyed by the pill below, so the stripe is dock-keyed.
const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300'; const accentClass = mooringLetterDot(berth.mooringNumber) ?? 'bg-slate-300';
@@ -168,14 +167,7 @@ export function BerthCard({ berth }: BerthCardProps) {
{/* Status pill + tags */} {/* Status pill + tags */}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5"> <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<span <StatusPill status={statusPill}>{statusLabel}</StatusPill>
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium',
statusColor,
)}
>
{statusLabel}
</span>
{tags.slice(0, 2).map((tag) => ( {tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} /> <TagBadge key={tag.id} name={tag.name} color={tag.color} />
))} ))}

View File

@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { formatCurrency } from '@/lib/utils/currency'; import { formatCurrency } from '@/lib/utils/currency';
import { mooringLetterDot } from './mooring-letter-tone'; import { mooringLetterDot } from './mooring-letter-tone';
import { stageBadgeClass, stageLabel } from '@/lib/constants'; import { stageBadgeClass, stageLabel } from '@/lib/constants';
@@ -112,25 +113,23 @@ export const BERTH_DEFAULT_HIDDEN: string[] = [
'pricingValidUntil', 'pricingValidUntil',
]; ];
function StatusBadge({ status }: { status: string }) { const BERTH_STATUS_PILL: Record<string, StatusPillStatus> = {
const variants: Record<string, string> = { available: 'available',
available: 'bg-green-100 text-green-800 border-green-200', under_offer: 'under_offer',
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-200', sold: 'sold',
sold: 'bg-red-100 text-red-800 border-red-200',
}; };
const labels: Record<string, string> = { const BERTH_STATUS_LABELS: Record<string, string> = {
available: 'Available', available: 'Available',
under_offer: 'Under Offer', under_offer: 'Under Offer',
sold: 'Sold', sold: 'Sold',
}; };
function StatusBadge({ status }: { status: string }) {
return ( return (
<span <StatusPill status={BERTH_STATUS_PILL[status] ?? 'pending'}>
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${variants[status] ?? 'bg-muted text-muted-foreground'}`} {BERTH_STATUS_LABELS[status] ?? status}
> </StatusPill>
{labels[status] ?? status}
</span>
); );
} }

View File

@@ -26,6 +26,7 @@ import {
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip'; import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthForm } from './berth-form'; import { BerthForm } from './berth-form';
import { mooringLetterDot } from './mooring-letter-tone'; import { mooringLetterDot } from './mooring-letter-tone';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -86,18 +87,18 @@ interface BerthDetailHeaderProps {
berth: BerthDetailData; berth: BerthDetailData;
} }
const STATUS_COLORS: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-300',
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-300',
sold: 'bg-red-100 text-red-800 border-red-300',
};
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
available: 'Available', available: 'Available',
under_offer: 'Under Offer', under_offer: 'Under Offer',
sold: 'Sold', sold: 'Sold',
}; };
const BERTH_STATUS_PILL: Record<string, StatusPillStatus> = {
available: 'available',
under_offer: 'under_offer',
sold: 'sold',
};
interface InterestOption { interface InterestOption {
id: string; id: string;
clientName: string; clientName: string;
@@ -276,11 +277,12 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
> >
{berth.mooringNumber} {berth.mooringNumber}
</div> </div>
<span <StatusPill
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`} status={BERTH_STATUS_PILL[berth.status] ?? 'pending'}
className="px-3 py-1 text-sm"
> >
{STATUS_LABELS[berth.status] ?? berth.status} {STATUS_LABELS[berth.status] ?? berth.status}
</span> </StatusPill>
</div> </div>
<div className="flex flex-wrap items-center gap-2 sm:shrink-0"> <div className="flex flex-wrap items-center gap-2 sm:shrink-0">

View File

@@ -97,7 +97,7 @@ function lastActivityFor(interest: ClientInterestRow): string | null {
} }
/** Full interest record returned by `/api/v1/interests/[id]`. Only the fields /** Full interest record returned by `/api/v1/interests/[id]`. Only the fields
* the drawer actually reads are typed here; the API returns more. */ * the preview sheet actually reads are typed here; the API returns more. */
interface InterestDetail { interface InterestDetail {
id: string; id: string;
pipelineStage: string; pipelineStage: string;
@@ -114,7 +114,7 @@ interface InterestDetail {
function useInterestDetail(id: string | null) { function useInterestDetail(id: string | null) {
return useQuery<{ data: InterestDetail }>({ return useQuery<{ data: InterestDetail }>({
queryKey: ['interest-detail-drawer', id], queryKey: ['interest-detail-preview', id],
queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`), queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`),
enabled: id !== null, enabled: id !== null,
// Detail rarely changes during a single drawer-open session; stale-time // Detail rarely changes during a single drawer-open session; stale-time
@@ -132,7 +132,7 @@ function formatDate(value: string | null | undefined): string | null {
return format(d, 'MMM d, yyyy'); return format(d, 'MMM d, yyyy');
} }
/** A single milestone row inside the drawer's milestone summary. Filled /** A single milestone row inside the preview sheet's milestone summary. Filled
* circle when the step is done, hollow when pending. Trailing meta line * circle when the step is done, hollow when pending. Trailing meta line
* shows the date stamp or a "pending" hint. */ * shows the date stamp or a "pending" hint. */
function MilestoneRow({ function MilestoneRow({
@@ -162,14 +162,13 @@ function MilestoneRow({
} }
/** /**
* Bottom-sheet preview of a single interest. Designed for the mobile * Right-side sheet preview of a single interest. "Tap an interest → see
* "tap an interest → see what's happening without leaving the client * what's happening without leaving the client page". Shows the pipeline
* page" flow. Shows the pipeline progress, a compact milestone summary * progress, a compact milestone summary (EOI / Deposit / Contract),
* (EOI / Deposit / Contract), lead context, last contact, and a notes * lead context, last contact, and a notes teaser. Tap-out / Esc
* teaser. Tap-out / drag-down dismisses; the full edit page is one tap * dismisses; the full edit page is one tap away via "Open full page →".
* away via "Open full page →".
*/ */
function InterestPreviewDrawer({ function InterestPreviewSheet({
interest, interest,
portSlug, portSlug,
onClose, onClose,
@@ -178,10 +177,10 @@ function InterestPreviewDrawer({
portSlug: string; portSlug: string;
onClose: () => void; onClose: () => void;
}) { }) {
// Pin the most recently selected interest so the drawer stays populated // Pin the most recently selected interest so the sheet stays populated
// during the close-animation tail (Vaul keeps the content mounted ~250ms // during the close-animation tail (Radix keeps the content mounted
// after `open=false`). Conditional setState is safe here - the guard // through the slide-out). Conditional setState is safe here - the
// ensures it only fires when the prop actually changes to a new row. // guard ensures it only fires when the prop actually changes.
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest); const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
if (interest && interest !== pinned) setPinned(interest); if (interest && interest !== pinned) setPinned(interest);
const showing = pinned; const showing = pinned;
@@ -448,7 +447,7 @@ export function ClientInterestsTab({ clientId }: ClientInterestsTabProps) {
</div> </div>
) : null} ) : null}
<InterestPreviewDrawer <InterestPreviewSheet
interest={previewInterest} interest={previewInterest}
portSlug={portSlug} portSlug={portSlug}
onClose={() => setPreviewInterest(null)} onClose={() => setPreviewInterest(null)}

View File

@@ -29,6 +29,13 @@ const statusPillVariants = cva(
// Delivered (non-signature docs in hub) // Delivered (non-signature docs in hub)
delivered: 'border-purple-light bg-purple-light/40 text-purple-dark', delivered: 'border-purple-light bg-purple-light/40 text-purple-dark',
draft: 'border-slate-200 bg-white text-slate-600', draft: 'border-slate-200 bg-white text-slate-600',
// Berth lifecycle
available: 'border-success-border bg-success-bg text-success',
under_offer: 'border-warning-border bg-warning-bg text-warning',
sold: 'border-error-border bg-error-bg text-error',
// User/account lifecycle
enabled: 'border-success-border bg-success-bg text-success',
disabled: 'border-slate-200 bg-slate-100 text-slate-500',
}, },
}, },
defaultVariants: { defaultVariants: {