Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions sdk-app/src/design/prototypes/agent-payments/AgentPaymentDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import type { AgentPayment, AgentPaymentStatus, TaxLiabilityPaymentState } from './types'
import { deriveAgentPaymentStatus } from './types'
import styles from './AgentPaymentsFlow.module.scss'
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
import { Flex } from '@/components/Common'

const STATUS_LABELS: Record<AgentPaymentStatus, string> = {
draft: 'Draft',
pending: 'Pending',
paid: 'Paid',
overdue: 'Overdue',
refunded: 'Refunded',
}

const STATUS_BADGE_VARIANTS: Record<AgentPaymentStatus, 'info' | 'warning' | 'success' | 'error'> =
{
draft: 'info',
pending: 'warning',
paid: 'success',
overdue: 'error',
refunded: 'info',
}

const LIABILITY_STATE_LABELS: Record<TaxLiabilityPaymentState, string> = {
paid: 'Paid',
pending: 'Pending',
refunded: 'Refunded',
}

const LIABILITY_STATE_BADGE_VARIANTS: Record<
TaxLiabilityPaymentState,
'info' | 'warning' | 'success' | 'error'
> = {
paid: 'success',
pending: 'warning',
refunded: 'info',
}

function formatUSD(amount: string): string {
const num = parseFloat(amount)
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(num)
}

const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })

function formatRelative(iso: string): string {
const diff = (new Date(iso).getTime() - Date.now()) / 1000
const abs = Math.abs(diff)
if (abs < 60) return rtf.format(Math.round(diff), 'second')
if (abs < 3600) return rtf.format(Math.round(diff / 60), 'minute')
if (abs < 86400) return rtf.format(Math.round(diff / 3600), 'hour')
if (abs < 2592000) return rtf.format(Math.round(diff / 86400), 'day')
if (abs < 31536000) return rtf.format(Math.round(diff / 2592000), 'month')
return rtf.format(Math.round(diff / 31536000), 'year')
}

function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}

function formatDateTimeWithRelative(iso: string): string {
return `${formatRelative(iso)} — ${formatDateTime(iso)}`
}

function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}

function truncateUuid(uuid: string): string {
if (uuid.length <= 13) return uuid
return `${uuid.slice(0, 8)}…${uuid.slice(-4)}`
}

interface AgentPaymentDetailProps {
payment: AgentPayment
onBack: () => void
}

export function AgentPaymentDetail({ payment, onBack }: AgentPaymentDetailProps) {
const { Alert, Badge, Button, DescriptionList, Heading, Text, Table } = useComponentContext()

const today = new Date().toISOString().slice(0, 10)
const status = deriveAgentPaymentStatus(payment, today)

const liabilityRows = (payment.tax_liabilities ?? []).map((liability, idx) => ({
key: `${liability.payroll_uuid}-${idx}`,
data: [
{
key: 'tax_description',
content: (
<Text as="span" size="sm">
{liability.tax_description}
</Text>
),
},
{
key: 'check_date',
content: (
<Text as="span" size="sm" variant="supporting">
{formatDate(liability.check_date)}
</Text>
),
},
{
key: 'payroll_uuid',
content: (
<span className={styles.payrollUuid} title={liability.payroll_uuid}>
{truncateUuid(liability.payroll_uuid)}
</span>
),
},
{
key: 'amount',
content: (
<Text as="span" size="sm" variant="supporting">
{formatUSD(liability.amount)}
</Text>
),
},
{
key: 'state',
content: (
<Badge status={LIABILITY_STATE_BADGE_VARIANTS[liability.payment_state]}>
{LIABILITY_STATE_LABELS[liability.payment_state]}
</Badge>
),
},
],
}))

return (
<Flex flexDirection="column" gap={24}>
<div>
<Button variant="secondary" onClick={onBack}>
← Back to payments
</Button>
</div>

<Flex flexDirection="column" gap={4}>
<div className={styles.detailHeader}>
<Heading as="h2">{payment.agent_name}</Heading>
<Badge status={STATUS_BADGE_VARIANTS[status]} className={styles.statusBadgeLg}>
{STATUS_LABELS[status]}
</Badge>
</div>
<Text variant="supporting">{payment.payment_type}</Text>
</Flex>

{status === 'overdue' && (
<Alert status="warning" label="Payment overdue">
<Text size="sm">
This payment was due on {formatDate(payment.due_date)} and has not been paid. Please
take action to avoid penalties.
</Text>
</Alert>
)}

<DescriptionList
layout="horizontal"
items={[
{
term: 'Payment Type',
description: payment.payment_type,
},
{
term: 'Description',
description: payment.description,
},
{
term: 'Due Date',
description: formatDate(payment.due_date),
},
{
term: 'Paid',
description: payment.paid_at ? formatDateTimeWithRelative(payment.paid_at) : '—',
},
{
term: 'Amount',
description: formatUSD(payment.amount),
},
]}
/>

{(payment.tax_liabilities ?? []).length > 0 && (
<Flex flexDirection="column" gap={16}>
<Heading as="h3">Tax Liabilities</Heading>
<Table
aria-label="Tax liabilities"
headers={[
{ key: 'tax_description', content: 'Tax Description' },
{ key: 'check_date', content: 'Check Date' },
{ key: 'payroll_uuid', content: 'Payroll' },
{ key: 'amount', content: 'Amount' },
{ key: 'state', content: 'State' },
]}
rows={liabilityRows}
emptyState={
<Text variant="supporting" size="sm">
No tax liabilities.
</Text>
}
/>
</Flex>
)}
</Flex>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
.searchRow {
max-width: 500px;
}

.alertLink {
background: none;
border: none;
padding: 0;
font: inherit;
font-weight: 600;
color: inherit;
text-decoration: underline;
cursor: pointer;

&:hover {
opacity: 0.8;
}
}

.filterRow {
display: flex;
flex-wrap: wrap;
gap: 12px;

> * {
flex: 1 1 160px;
min-width: 140px;
}
}

.sortButton {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
font: inherit;
font-weight: 600;
font-size: var(--g-fontSizeSmall, 0.875rem);
color: inherit;
cursor: pointer;
white-space: nowrap;

&:hover {
color: var(--g-colorPrimary, #0a8080);
}
}

.sortIcon {
width: 12px;
height: 12px;
opacity: 0.3;
transition: transform 0.15s ease, opacity 0.15s ease;
flex-shrink: 0;

&[data-active] {
opacity: 1;
color: var(--g-colorPrimary, #0a8080);
}

&[data-dir='asc'] {
transform: rotate(180deg);
}
}

.agentName {
font-weight: 600;
}

.statusBadgeLg {
align-self: center;
}

.detailHeader {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 12px;
}

.documentRow {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
}

.payrollUuid {
font-family: monospace;
font-size: var(--g-fontSizeSmall, 0.875rem);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createMachine } from 'robot3'
import { useMemo } from 'react'
import { agentPaymentsMachine } from './agentPaymentsStateMachine'
import type {
AgentPaymentsFlowProps,
AgentPaymentsFlowContextInterface,
} from './AgentPaymentsFlowComponents'
import { AgentPaymentsListContextual } from './AgentPaymentsFlowComponents'
import { Flow } from '@/components/Flow/Flow'

export const AgentPaymentsFlow = ({ companyId, onEvent }: AgentPaymentsFlowProps) => {
const machine = useMemo(
() =>
createMachine(
'list',
agentPaymentsMachine,
(initialContext: AgentPaymentsFlowContextInterface) => ({
...initialContext,
component: AgentPaymentsListContextual,
companyId,
selectedPaymentUuid: null,
header: null,
}),
),
[companyId],
)

return <Flow machine={machine} onEvent={onEvent} />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { mockAgentPayments } from './agentPaymentsMockData'
import { AgentPaymentsList } from './AgentPaymentsList'
import { AgentPaymentDetail } from './AgentPaymentDetail'
import { agentPaymentsEvents } from './events'
import type { BaseComponentInterface } from '@/components/Base'
import { useFlow, type FlowContextInterface } from '@/components/Flow/useFlow'
import { ensureRequired } from '@/helpers/ensureRequired'
import type { EventType } from '@/shared/constants'

export interface AgentPaymentsFlowProps extends BaseComponentInterface {
companyId: string
}

export interface AgentPaymentsFlowContextInterface extends FlowContextInterface {
companyId?: string
selectedPaymentUuid: string | null
}

export function AgentPaymentsListContextual() {
const { onEvent } = useFlow<AgentPaymentsFlowContextInterface>()

return (
<AgentPaymentsList
payments={mockAgentPayments}
onSelectPayment={uuid => {
onEvent(agentPaymentsEvents.AGENT_PAYMENT_SELECTED as EventType, uuid)
}}
/>
)
}

export function AgentPaymentDetailContextual() {
const { onEvent, selectedPaymentUuid } = useFlow<AgentPaymentsFlowContextInterface>()
const uuid = ensureRequired(selectedPaymentUuid)
const payment = mockAgentPayments.find(p => p.uuid === uuid)

if (!payment) return null

return (
<AgentPaymentDetail
payment={payment}
onBack={() => {
onEvent(agentPaymentsEvents.AGENT_PAYMENT_BACK as EventType)
}}
/>
)
}
Loading
Loading