+ {/* Table (sm and up) */}
+
@@ -220,6 +225,38 @@ export default function ContractorListPage() {
+ {/* Cards (below sm) */}
+
+ {isLoading ? (
+
+ ) : contractors.length === 0 ? (
+
+ {search || statusFilter
+ ? 'No contractors match your filters'
+ : 'No contractors yet. Add your first contractor to get started.'}
+
+ ) : (
+ contractors.map((contractor) => (
+ }
+ >
+ {contractor.email}
+
+ {contractor.type}
+
+
+ {formatDate(contractor.createdAt)}
+
+
+ ))
+ )}
+
+
{/* Pagination */}
{meta && meta.totalPages > 1 && (
diff --git a/apps/web/src/app/(admin)/documents/page.tsx b/apps/web/src/app/(admin)/documents/page.tsx
index 9bf22bb..be300a1 100644
--- a/apps/web/src/app/(admin)/documents/page.tsx
+++ b/apps/web/src/app/(admin)/documents/page.tsx
@@ -4,8 +4,25 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api-client';
import { DocumentStatusBadge } from '@/components/documents/document-status-badge';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
import { DOCUMENT_TYPE_LABELS, type TaxDocumentType, type ComplianceReportEntry } from '@contractor-os/shared';
+function CompliantBadge({ compliant }: { compliant: boolean }) {
+ return compliant ? (
+
+ Yes
+
+ ) : (
+
+ No
+
+ );
+}
+
const FILTER_TABS = [
{ label: 'All', value: 'all' },
{ label: 'Compliant', value: 'compliant' },
@@ -80,8 +97,8 @@ export default function DocumentVaultPage() {
- {/* Table */}
-
+ {/* Table (sm and up) */}
+
{isLoading ? (
@@ -145,15 +162,7 @@ export default function DocumentVaultPage() {
)}
- {entry.isCompliant ? (
-
- Yes
-
- ) : (
-
- No
-
- )}
+
);
@@ -162,6 +171,63 @@ export default function DocumentVaultPage() {
)}
+
+ {/* Cards (below sm) */}
+
+ {isLoading ? (
+
+ ) : filtered.length === 0 ? (
+
+ No contractors match this filter.
+
+ ) : (
+ filtered.map((entry) => {
+ const taxFormType =
+ entry.contractorType === 'foreign' ? 'w8ben' : 'w9';
+ const hasTaxForm = entry.currentDocuments.includes(
+ taxFormType as TaxDocumentType,
+ );
+ const hasContract = entry.currentDocuments.includes(
+ 'contract' as TaxDocumentType,
+ );
+ return (
+ }
+ >
+
+
+
+
+
+
+
+ {entry.expiringDocuments.length > 0 ? (
+
+ {entry.expiringDocuments
+ .map((d) => getDocTypeLabel(d.type))
+ .join(', ')}
+
+ ) : (
+ None
+ )}
+
+
+ );
+ })
+ )}
+
);
}
diff --git a/apps/web/src/app/(admin)/invoices/[id]/page.tsx b/apps/web/src/app/(admin)/invoices/[id]/page.tsx
index db20f7a..50df991 100644
--- a/apps/web/src/app/(admin)/invoices/[id]/page.tsx
+++ b/apps/web/src/app/(admin)/invoices/[id]/page.tsx
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { api, ApiClientError } from '@/lib/api-client';
import { formatDate, formatCurrency } from '@/lib/format';
import { Button } from '@/components/ui/button';
+import { TableScroll } from '@/components/ui/responsive-table';
import { InvoiceStatusBadge } from '@/components/invoices/invoice-status-badge';
import { InvoiceTimeline } from '@/components/invoices/invoice-timeline';
import type { InvoiceDetail, InvoiceStatus } from '@contractor-os/shared';
@@ -241,20 +242,21 @@ export default function InvoiceDetailPage() {
{/* Line items table */}
-
+
Line Items
+
Description
-
+
Qty
-
+
Unit Price
@@ -266,10 +268,10 @@ export default function InvoiceDetailPage() {
{invoice.lineItems.map((item) => (
{item.description}
-
+
{item.quantity}
-
+
{formatCurrency(item.unitPrice)}
@@ -279,6 +281,7 @@ export default function InvoiceDetailPage() {
))}
+
{/* Notes */}
diff --git a/apps/web/src/app/(admin)/invoices/page.tsx b/apps/web/src/app/(admin)/invoices/page.tsx
index adc902f..a937743 100644
--- a/apps/web/src/app/(admin)/invoices/page.tsx
+++ b/apps/web/src/app/(admin)/invoices/page.tsx
@@ -5,6 +5,11 @@ import { useRouter } from 'next/navigation';
import { api } from '@/lib/api-client';
import { formatDate, formatCurrency } from '@/lib/format';
import { InvoiceStatusBadge } from '@/components/invoices/invoice-status-badge';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
import type { InvoiceListItem, PaginationMeta, InvoiceStatus } from '@contractor-os/shared';
const STATUS_TABS = [
@@ -78,8 +83,8 @@ export default function InvoicesPage() {
- {/* Table */}
-
+ {/* Table (sm and up) */}
+
{isLoading ? (
@@ -144,6 +149,45 @@ export default function InvoicesPage() {
)}
+ {/* Cards (below sm) */}
+
+ {isLoading ? (
+
+ ) : invoices.length === 0 ? (
+
+ No invoices found.
+
+ ) : (
+ invoices.map((inv) => (
+ {inv.invoiceNumber}}
+ accessory={
+
+ }
+ >
+
+ {inv.contractorName}
+
+
+
+ {formatCurrency(inv.totalAmount)}
+
+
+
+ {formatDate(inv.submittedAt)}
+
+
+ {formatDate(inv.dueDate)}
+
+
+ ))
+ )}
+
+
{/* Pagination */}
{meta && meta.totalPages > 1 && (
diff --git a/apps/web/src/app/(admin)/offboarding/page.tsx b/apps/web/src/app/(admin)/offboarding/page.tsx
index f2aaa46..6b4a3aa 100644
--- a/apps/web/src/app/(admin)/offboarding/page.tsx
+++ b/apps/web/src/app/(admin)/offboarding/page.tsx
@@ -6,6 +6,11 @@ import { api } from '@/lib/api-client';
import { formatDate } from '@/lib/format';
import type { OffboardingWorkflow, OffboardingStatus } from '@contractor-os/shared';
import { OffboardingStatusBadge } from '@/components/offboarding/offboarding-status-badge';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
const STATUS_TABS = [
{ label: 'All', value: '' },
@@ -107,7 +112,7 @@ export default function OffboardingPage() {
) : (
<>
-
+
@@ -176,6 +181,46 @@ export default function OffboardingPage() {
+ {/* Cards (below sm) */}
+
+ {workflows.map((w) => (
+
+ }
+ >
+
+ {REASON_LABELS[w.reason] ?? w.reason}
+
+
+
+
+
+
+
+ {w.progress}%
+
+
+
+
+ {formatDate(w.effectiveDate)}
+
+
+ {formatDate(w.createdAt)}
+
+
+ ))}
+
+
{/* Pagination */}
{meta.totalPages > 1 && (
diff --git a/apps/web/src/app/(admin)/tax/page.tsx b/apps/web/src/app/(admin)/tax/page.tsx
index b0e9027..45b0403 100644
--- a/apps/web/src/app/(admin)/tax/page.tsx
+++ b/apps/web/src/app/(admin)/tax/page.tsx
@@ -4,8 +4,31 @@ import { useState, useEffect } from 'react';
import { api } from '@/lib/api-client';
import { formatCurrency } from '@/lib/format';
import { DocumentStatusBadge } from '@/components/documents/document-status-badge';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
import type { ReadinessEntry1099 } from '@contractor-os/shared';
+function ReadyBadge({ entry }: { entry: ReadinessEntry1099 }) {
+ if (entry.isReady) {
+ return (
+
+ Ready
+
+ );
+ }
+ if (entry.requires1099) {
+ return (
+
+ Not Ready
+
+ );
+ }
+ return N/A ;
+}
+
export default function TaxReadinessPage() {
const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear);
@@ -64,8 +87,8 @@ export default function TaxReadinessPage() {
- {/* Table */}
-
+ {/* Table (sm and up) */}
+
{isLoading ? (
@@ -115,17 +138,7 @@ export default function TaxReadinessPage() {
)}
- {entry.isReady ? (
-
- Ready
-
- ) : entry.requires1099 ? (
-
- Not Ready
-
- ) : (
- N/A
- )}
+
))}
@@ -133,6 +146,45 @@ export default function TaxReadinessPage() {
)}
+
+ {/* Cards (below sm) */}
+
+ {isLoading ? (
+
+ ) : entries.length === 0 ? (
+
+ No domestic contractors found.
+
+ ) : (
+ entries.map((entry) => (
+ }
+ >
+
+
+ {formatCurrency(entry.ytdPayments)}
+
+
+
+
+
+
+ {entry.requires1099 ? (
+ Yes
+ ) : (
+ No
+ )}
+
+
+ ))
+ )}
+
);
}
diff --git a/apps/web/src/app/(portal)/portal/dashboard/page.tsx b/apps/web/src/app/(portal)/portal/dashboard/page.tsx
index 0a5e6d0..beff51f 100644
--- a/apps/web/src/app/(portal)/portal/dashboard/page.tsx
+++ b/apps/web/src/app/(portal)/portal/dashboard/page.tsx
@@ -193,7 +193,8 @@ export default function PortalDashboardPage() {
{recentInvoices.length > 0 ? (
-
+ <>
+
Invoice #
@@ -229,6 +230,57 @@ export default function PortalDashboardPage() {
))}
+
+ {/* Recent invoices list (below sm) */}
+
+ {recentInvoices.map((inv) => (
+
+
+
+
+ {inv.invoiceNumber}
+
+
+
+ {inv.status.replace(/_/g, ' ')}
+
+
+
+
+
+ $
+ {inv.totalAmount.toLocaleString('en-US', {
+ minimumFractionDigits: 2,
+ })}
+
+
+ {new Date(
+ inv.submittedAt ?? inv.createdAt,
+ ).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ })}
+
+
+
+
+ ))}
+
+ >
) : (
No invoices yet.
diff --git a/apps/web/src/app/(portal)/portal/documents/page.tsx b/apps/web/src/app/(portal)/portal/documents/page.tsx
index de29472..8bed8f5 100644
--- a/apps/web/src/app/(portal)/portal/documents/page.tsx
+++ b/apps/web/src/app/(portal)/portal/documents/page.tsx
@@ -5,6 +5,11 @@ import { api } from '@/lib/api-client';
import { formatDate } from '@/lib/format';
import { DocumentStatusBadge, getDocumentStatus } from '@/components/documents/document-status-badge';
import { UploadModal } from '@/components/documents/upload-modal';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
import {
DOCUMENT_TYPE_LABELS,
REQUIRED_DOCUMENTS_DOMESTIC,
@@ -140,8 +145,8 @@ export default function PortalDocumentsPage() {
)}
- {/* Documents table */}
-
+ {/* Documents table (sm and up) */}
+
{documents.length === 0 ? (
No documents uploaded yet.
@@ -204,6 +209,40 @@ export default function PortalDocumentsPage() {
)}
+ {/* Cards (below sm) */}
+
+ {documents.length === 0 ? (
+
+ No documents uploaded yet.
+
+ ) : (
+ documents.map((doc) => (
+ handleDownload(doc)}
+ title={
+ DOCUMENT_TYPE_LABELS[doc.documentType as TaxDocumentType] ??
+ doc.documentType
+ }
+ subtitle={doc.fileName}
+ accessory={ }
+ >
+
+ {formatDate(doc.createdAt)}
+
+
+ {formatDate(doc.expiresAt)}
+
+
+
+ Download
+
+
+
+ ))
+ )}
+
+
{showUpload && contractorId && (
Line Items
+
Description
-
+
Qty
-
+
Rate
@@ -187,10 +189,10 @@ export default function PortalInvoiceDetailPage() {
{invoice.lineItems.map((item) => (
{item.description}
-
+
{item.quantity}
-
+
{formatCurrency(item.unitPrice)}
@@ -200,6 +202,7 @@ export default function PortalInvoiceDetailPage() {
))}
+
{/* Notes */}
diff --git a/apps/web/src/app/(portal)/portal/invoices/page.tsx b/apps/web/src/app/(portal)/portal/invoices/page.tsx
index 1c5619a..c21f71e 100644
--- a/apps/web/src/app/(portal)/portal/invoices/page.tsx
+++ b/apps/web/src/app/(portal)/portal/invoices/page.tsx
@@ -6,6 +6,11 @@ import { api } from '@/lib/api-client';
import { formatDate, formatCurrency } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { InvoiceStatusBadge } from '@/components/invoices/invoice-status-badge';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
import type { InvoiceListItem, PaginationMeta, InvoiceStatus } from '@contractor-os/shared';
const STATUS_TABS = [
@@ -78,8 +83,8 @@ export default function PortalInvoicesPage() {
- {/* Table */}
-
+ {/* Table (sm and up) */}
+
{isLoading ? (
@@ -145,6 +150,49 @@ export default function PortalInvoicesPage() {
)}
+ {/* Cards (below sm) */}
+
+ {isLoading ? (
+
+ ) : invoices.length === 0 ? (
+
+
No invoices yet.
+
router.push('/portal/invoices/new')}
+ >
+ Create Your First Invoice
+
+
+ ) : (
+ invoices.map((inv) => (
+ {inv.invoiceNumber}}
+ accessory={
+
+ }
+ >
+
+
+ {formatCurrency(inv.totalAmount)}
+
+
+
+ {formatDate(inv.periodStart)} – {formatDate(inv.periodEnd)}
+
+
+ {formatDate(inv.dueDate)}
+
+
+ ))
+ )}
+
+
{/* Pagination */}
{meta && meta.totalPages > 1 && (
diff --git a/apps/web/src/app/(portal)/portal/payments/page.tsx b/apps/web/src/app/(portal)/portal/payments/page.tsx
index 37608ad..a02070e 100644
--- a/apps/web/src/app/(portal)/portal/payments/page.tsx
+++ b/apps/web/src/app/(portal)/portal/payments/page.tsx
@@ -5,6 +5,11 @@ import { useRouter } from 'next/navigation';
import { api } from '@/lib/api-client';
import { formatDate, formatCurrency } from '@/lib/format';
import { InvoiceStatusBadge } from '@/components/invoices/invoice-status-badge';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
import type { InvoiceListItem, PaginationMeta } from '@contractor-os/shared';
const STATUS_TABS = [
@@ -134,8 +139,8 @@ export default function PortalPaymentsPage() {
- {/* Payments Table */}
-
+ {/* Payments Table (sm and up) */}
+
@@ -216,6 +221,40 @@ export default function PortalPaymentsPage() {
+ {/* Cards (below sm) */}
+
+ {isLoading ? (
+
+ ) : invoices.length === 0 ? (
+
+ No payments found
+
+ ) : (
+ invoices.map((inv) => (
+ {inv.invoiceNumber}}
+ accessory={ }
+ >
+
+
+ {formatCurrency(inv.totalAmount)}
+
+
+
+ {formatDate(inv.periodStart)} – {formatDate(inv.periodEnd)}
+
+
+ {formatDate(inv.dueDate)}
+
+
+ ))
+ )}
+
+
{/* Pagination */}
{meta && meta.totalPages > 1 && (
diff --git a/apps/web/src/app/(portal)/portal/time-entries/page.tsx b/apps/web/src/app/(portal)/portal/time-entries/page.tsx
index da42436..0379de5 100644
--- a/apps/web/src/app/(portal)/portal/time-entries/page.tsx
+++ b/apps/web/src/app/(portal)/portal/time-entries/page.tsx
@@ -6,6 +6,11 @@ import { api } from '@/lib/api-client';
import { formatDate } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { TimeEntryForm } from '@/components/time-entries/time-entry-form';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
export default function PortalTimeEntriesPage() {
const [entries, setEntries] = useState
([]);
@@ -111,7 +116,8 @@ export default function PortalTimeEntriesPage() {
No time entries yet. Click "Log Time" to get started.
) : (
-
+ <>
+
@@ -155,6 +161,32 @@ export default function PortalTimeEntriesPage() {
+
+ {/* Cards (below sm) */}
+
+ {entries.map((entry) => (
+
+
+
+ {entry.hours.toFixed(1)}
+
+
+
+ {entry.description}
+
+
+ handleDelete(entry.id)}
+ className="text-xs font-medium text-error-600 hover:text-error-700"
+ >
+ Delete
+
+
+
+ ))}
+
+ >
)}
{/* Pagination */}
diff --git a/apps/web/src/components/documents/documents-tab.tsx b/apps/web/src/components/documents/documents-tab.tsx
index 0153361..9b9f85b 100644
--- a/apps/web/src/components/documents/documents-tab.tsx
+++ b/apps/web/src/components/documents/documents-tab.tsx
@@ -5,6 +5,11 @@ import { api } from '@/lib/api-client';
import { formatDate } from '@/lib/format';
import { DocumentStatusBadge, getDocumentStatus } from './document-status-badge';
import { UploadModal } from './upload-modal';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
import { DOCUMENT_TYPE_LABELS, type TaxDocumentType, type TaxDocument } from '@contractor-os/shared';
interface DocumentsTabProps {
@@ -73,7 +78,8 @@ export function DocumentsTab({ contractorId }: DocumentsTabProps) {
No documents uploaded for this contractor.
) : (
-
+ <>
+
@@ -129,6 +135,35 @@ export function DocumentsTab({ contractorId }: DocumentsTabProps) {
+
+ {/* Cards (below sm) */}
+
+ {documents.map((doc) => (
+ handleDownload(doc)}
+ title={
+ DOCUMENT_TYPE_LABELS[doc.documentType as TaxDocumentType] ??
+ doc.documentType
+ }
+ subtitle={doc.fileName}
+ accessory={ }
+ >
+
+ {formatDate(doc.createdAt)}
+
+
+ {formatDate(doc.expiresAt)}
+
+
+
+ Download
+
+
+
+ ))}
+
+ >
)}
{showUpload && (
diff --git a/apps/web/src/components/engagements/engagements-tab.tsx b/apps/web/src/components/engagements/engagements-tab.tsx
index 22b0dc6..10c19b0 100644
--- a/apps/web/src/components/engagements/engagements-tab.tsx
+++ b/apps/web/src/components/engagements/engagements-tab.tsx
@@ -5,6 +5,11 @@ import type { Engagement } from '@contractor-os/shared';
import { api } from '@/lib/api-client';
import { formatDate, formatCurrency } from '@/lib/format';
import { Button } from '@/components/ui/button';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
import { EngagementStatusBadge } from './engagement-status-badge';
import { EngagementForm } from './engagement-form';
@@ -67,7 +72,8 @@ export function EngagementsTab({ contractorId }: EngagementsTabProps) {
No engagements yet.
) : (
-
+ <>
+
@@ -117,6 +123,33 @@ export function EngagementsTab({ contractorId }: EngagementsTabProps) {
+
+ {/* Cards (below sm) */}
+
+ {engagements.map((e) => (
+ }
+ >
+
+ {formatDate(e.startDate)}
+
+
+ {formatDate(e.endDate)}
+
+
+
+ {formatRate(e)}
+
+
+
+ {formatPaymentTerms(e.paymentTerms)}
+
+
+ ))}
+
+ >
)}
{showForm && (
diff --git a/apps/web/src/components/invoices/invoices-tab.tsx b/apps/web/src/components/invoices/invoices-tab.tsx
index efce0b9..8b86d39 100644
--- a/apps/web/src/components/invoices/invoices-tab.tsx
+++ b/apps/web/src/components/invoices/invoices-tab.tsx
@@ -4,6 +4,11 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api-client';
import { formatDate, formatCurrency } from '@/lib/format';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
import { InvoiceStatusBadge } from './invoice-status-badge';
import type { InvoiceListItem, InvoiceStatus } from '@contractor-os/shared';
@@ -50,7 +55,8 @@ export function InvoicesTab({ contractorId }: InvoicesTabProps) {
}
return (
-
+ <>
+
@@ -98,5 +104,32 @@ export function InvoicesTab({ contractorId }: InvoicesTabProps) {
+
+ {/* Cards (below sm) */}
+
+ {invoices.map((inv) => (
+ {inv.invoiceNumber}}
+ accessory={
+
+ }
+ >
+
+
+ {formatCurrency(inv.totalAmount)}
+
+
+
+ {formatDate(inv.periodStart)} – {formatDate(inv.periodEnd)}
+
+
+ {formatDate(inv.dueDate)}
+
+
+ ))}
+
+ >
);
}
diff --git a/apps/web/src/components/time-entries/time-entries-tab.tsx b/apps/web/src/components/time-entries/time-entries-tab.tsx
index 498edbc..d2c721b 100644
--- a/apps/web/src/components/time-entries/time-entries-tab.tsx
+++ b/apps/web/src/components/time-entries/time-entries-tab.tsx
@@ -4,6 +4,11 @@ import { useState, useEffect, useCallback } from 'react';
import type { TimeEntry, PaginationMeta } from '@contractor-os/shared';
import { api } from '@/lib/api-client';
import { formatDate } from '@/lib/format';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+} from '@/components/ui/responsive-table';
interface TimeEntriesTabProps {
contractorId: string;
@@ -62,7 +67,8 @@ export function TimeEntriesTab({ contractorId }: TimeEntriesTabProps) {
No time entries yet.
) : (
-
+ <>
+
@@ -94,6 +100,23 @@ export function TimeEntriesTab({ contractorId }: TimeEntriesTabProps) {
+
+ {/* Cards (below sm) */}
+
+ {entries.map((entry) => (
+
+
+
+ {entry.hours.toFixed(1)}
+
+
+
+ {entry.description}
+
+
+ ))}
+
+ >
)}
{meta && meta.totalPages > 1 && (
diff --git a/apps/web/src/components/ui/responsive-table.stories.tsx b/apps/web/src/components/ui/responsive-table.stories.tsx
new file mode 100644
index 0000000..d0a682c
--- /dev/null
+++ b/apps/web/src/components/ui/responsive-table.stories.tsx
@@ -0,0 +1,104 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import {
+ MobileCard,
+ MobileCardList,
+ MobileCardRow,
+ TableScroll,
+} from './responsive-table';
+
+const meta: Meta
= {
+ title: 'UI/ResponsiveTable',
+ component: MobileCard,
+ parameters: { viewport: { defaultViewport: 'mobile1' } },
+};
+
+export default meta;
+type Story = StoryObj;
+
+const Badge = ({ label }: { label: string }) => (
+
+ {label}
+
+);
+
+export const CardStack: Story = {
+ render: () => (
+
+
+ }
+ >
+ john.smith@example.com
+ Foreign contractor
+ May 30, 2025
+
+ }
+ >
+ e.martinez@example.com
+ Domestic contractor
+ Jun 17, 2025
+
+
+
+ ),
+};
+
+export const Empty: Story = {
+ render: () => (
+
+
+
+ No contractors match your filters
+
+
+
+ ),
+};
+
+export const HorizontalScroll: Story = {
+ render: () => (
+
+
+
+
+
+
+ Description
+
+
+ Qty
+
+
+ Unit Price
+
+
+ Amount
+
+
+
+
+
+
+ Architecture and technical design
+
+
+ 1
+
+
+ $26,800.00
+
+
+ $26,800.00
+
+
+
+
+
+
+ ),
+};
diff --git a/apps/web/src/components/ui/responsive-table.tsx b/apps/web/src/components/ui/responsive-table.tsx
new file mode 100644
index 0000000..82ec829
--- /dev/null
+++ b/apps/web/src/components/ui/responsive-table.tsx
@@ -0,0 +1,131 @@
+'use client';
+
+import Link from 'next/link';
+import type { ReactNode } from 'react';
+
+/**
+ * Mobile-only card-stack rendering for entity-list tables.
+ *
+ * Tables across the app drop columns below the `sm` breakpoint via
+ * `hidden sm:table-cell`, which leaves only two columns visible and no way to
+ * reach the rest on a phone. The pattern here is: keep the existing ``
+ * for `sm` and up, and render the same rows as stacked cards below `sm`.
+ *
+ * `` is the `sm:hidden` container; the desktop table wrapper
+ * should be marked `hidden sm:block` so exactly one of the two renders.
+ */
+export function MobileCardList({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+ {children}
+ );
+}
+
+/**
+ * A single row rendered as a card. `title` is the most important field
+ * (e.g. contractor name, invoice number); `accessory` sits opposite the
+ * title and is typically a status badge. Remaining fields go in `children`
+ * as `` label/value pairs.
+ */
+export function MobileCard({
+ href,
+ onClick,
+ title,
+ subtitle,
+ accessory,
+ children,
+}: {
+ href?: string;
+ onClick?: () => void;
+ title: ReactNode;
+ subtitle?: ReactNode;
+ accessory?: ReactNode;
+ children?: ReactNode;
+}) {
+ const inner = (
+
+
+
+
+ {title}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ {accessory ?
{accessory}
: null}
+
+ {children ?
{children} : null}
+
+ );
+
+ if (href) {
+ return (
+
+ {inner}
+
+ );
+ }
+ if (onClick) {
+ return (
+
+ {inner}
+
+ );
+ }
+ return inner;
+}
+
+/** Label/value line inside a ``. */
+export function MobileCardRow({
+ label,
+ children,
+}: {
+ label: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Horizontal-scroll wrapper with a right-edge fade affordance on small
+ * screens, signalling that the table scrolls sideways. Used for dense
+ * data tables (e.g. invoice line items) where every column carries
+ * roughly equal weight and a card-stack would read worse than a table.
+ */
+export function TableScroll({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}