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,
} from '@/components/ui/dropdown-menu';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { StatusPill } from '@/components/ui/status-pill';
import {
ListCard,
ListCardAvatar,
@@ -169,13 +170,9 @@ export function UserCard({
{/* Status + super-admin pills */}
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
{!user.isActive ? (
<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.isActive ? <StatusPill status="disabled">Disabled</StatusPill> : null}
{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
</span>
) : null}

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { formatCurrency } from '@/lib/utils/currency';
import { mooringLetterDot } from './mooring-letter-tone';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
@@ -112,25 +113,23 @@ export const BERTH_DEFAULT_HIDDEN: string[] = [
'pricingValidUntil',
];
const BERTH_STATUS_PILL: Record<string, StatusPillStatus> = {
available: 'available',
under_offer: 'under_offer',
sold: 'sold',
};
const BERTH_STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
function StatusBadge({ status }: { status: string }) {
const 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 labels: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
return (
<span
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'}`}
>
{labels[status] ?? status}
</span>
<StatusPill status={BERTH_STATUS_PILL[status] ?? 'pending'}>
{BERTH_STATUS_LABELS[status] ?? status}
</StatusPill>
);
}

View File

@@ -26,6 +26,7 @@ import {
import { Textarea } from '@/components/ui/textarea';
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthForm } from './berth-form';
import { mooringLetterDot } from './mooring-letter-tone';
import { cn } from '@/lib/utils';
@@ -86,18 +87,18 @@ interface BerthDetailHeaderProps {
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> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
const BERTH_STATUS_PILL: Record<string, StatusPillStatus> = {
available: 'available',
under_offer: 'under_offer',
sold: 'sold',
};
interface InterestOption {
id: string;
clientName: string;
@@ -276,11 +277,12 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
>
{berth.mooringNumber}
</div>
<span
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'}`}
<StatusPill
status={BERTH_STATUS_PILL[berth.status] ?? 'pending'}
className="px-3 py-1 text-sm"
>
{STATUS_LABELS[berth.status] ?? berth.status}
</span>
</StatusPill>
</div>
<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
* 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 {
id: string;
pipelineStage: string;
@@ -114,7 +114,7 @@ interface InterestDetail {
function useInterestDetail(id: string | null) {
return useQuery<{ data: InterestDetail }>({
queryKey: ['interest-detail-drawer', id],
queryKey: ['interest-detail-preview', id],
queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`),
enabled: id !== null,
// 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');
}
/** 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
* shows the date stamp or a "pending" hint. */
function MilestoneRow({
@@ -162,14 +162,13 @@ function MilestoneRow({
}
/**
* Bottom-sheet preview of a single interest. Designed for the mobile
* "tap an interest → see what's happening without leaving the client
* page" flow. Shows the pipeline progress, a compact milestone summary
* (EOI / Deposit / Contract), lead context, last contact, and a notes
* teaser. Tap-out / drag-down dismisses; the full edit page is one tap
* away via "Open full page →".
* Right-side sheet preview of a single interest. "Tap an interest → see
* what's happening without leaving the client page". Shows the pipeline
* progress, a compact milestone summary (EOI / Deposit / Contract),
* lead context, last contact, and a notes teaser. Tap-out / Esc
* dismisses; the full edit page is one tap away via "Open full page →".
*/
function InterestPreviewDrawer({
function InterestPreviewSheet({
interest,
portSlug,
onClose,
@@ -178,10 +177,10 @@ function InterestPreviewDrawer({
portSlug: string;
onClose: () => void;
}) {
// Pin the most recently selected interest so the drawer stays populated
// during the close-animation tail (Vaul keeps the content mounted ~250ms
// after `open=false`). Conditional setState is safe here - the guard
// ensures it only fires when the prop actually changes to a new row.
// Pin the most recently selected interest so the sheet stays populated
// during the close-animation tail (Radix keeps the content mounted
// through the slide-out). Conditional setState is safe here - the
// guard ensures it only fires when the prop actually changes.
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
if (interest && interest !== pinned) setPinned(interest);
const showing = pinned;
@@ -448,7 +447,7 @@ export function ClientInterestsTab({ clientId }: ClientInterestsTabProps) {
</div>
) : null}
<InterestPreviewDrawer
<InterestPreviewSheet
interest={previewInterest}
portSlug={portSlug}
onClose={() => setPreviewInterest(null)}

View File

@@ -29,6 +29,13 @@ const statusPillVariants = cva(
// Delivered (non-signature docs in hub)
delivered: 'border-purple-light bg-purple-light/40 text-purple-dark',
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: {