diff --git a/apps/web/src/app/(admin)/audit/page.tsx b/apps/web/src/app/(admin)/audit/page.tsx index b4b261a..6abe87e 100644 --- a/apps/web/src/app/(admin)/audit/page.tsx +++ b/apps/web/src/app/(admin)/audit/page.tsx @@ -5,6 +5,11 @@ import { api } from '@/lib/api-client'; import type { AuditEvent } from '@contractor-os/shared'; import { AuditFilters } from '@/components/audit/audit-filters'; import { AuditDiffViewer } from '@/components/audit/audit-diff-viewer'; +import { + MobileCard, + MobileCardList, + MobileCardRow, +} from '@/components/ui/responsive-table'; type AuditEventWithEmail = AuditEvent & { userEmail?: string }; @@ -107,7 +112,7 @@ export default function AuditPage() { ) : ( <> -
+
@@ -185,6 +190,48 @@ export default function AuditPage() {
+ {/* Cards (below sm) */} + + {events.map((event) => ( + + + setExpandedId( + expandedId === event.id ? null : event.id, + ) + } + title={{event.entityType}} + subtitle={formatTimestamp(event.createdAt)} + accessory={} + > + + {event.userEmail ?? event.userId ?? '—'} + + + + {event.entityId?.slice(0, 8) ?? '—'} + + + + + {expandedId === event.id + ? 'Hide changes' + : 'View changes'} + + + + {expandedId === event.id && ( +
+ +
+ )} +
+ ))} +
+ {meta.totalPages > 1 && (
diff --git a/apps/web/src/app/(admin)/contractors/page.tsx b/apps/web/src/app/(admin)/contractors/page.tsx index 831abb8..e02313a 100644 --- a/apps/web/src/app/(admin)/contractors/page.tsx +++ b/apps/web/src/app/(admin)/contractors/page.tsx @@ -12,6 +12,11 @@ import { import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { ContractorStatusBadge } from '@/components/contractors/contractor-status-badge'; +import { + MobileCard, + MobileCardList, + MobileCardRow, +} from '@/components/ui/responsive-table'; import { useAuth } from '@/hooks/use-auth'; const STATUS_OPTIONS = [ @@ -135,8 +140,8 @@ export default function ContractorListPage() {
- {/* Table */} -
+ {/* 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 ? ( - + <> +
@@ -229,6 +230,57 @@ export default function PortalDashboardPage() { ))}
Invoice #
+ + {/* 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.

+ +
+ ) : ( + 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} + + + + + + ))} + + )} {/* 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 ( + + ); + } + 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 ( +
+
{children}
+
+
+ ); +}