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:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 }) {
|
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 (
|
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user