442 lines
25 KiB
React
442 lines
25 KiB
React
// frontend/src/pages/crm/customers/tabs/FinancialsTab.jsx
|
||
// Financial overview: overall stats, per-order payment status, transaction history
|
||
|
||
import { useState } from 'react'
|
||
import api from '@/lib/api'
|
||
import Card from '@/components/ui/Card'
|
||
import Button from '@/components/ui/Button'
|
||
import StatusBadge from '@/components/ui/StatusBadge'
|
||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||
import { useToast } from '@/components/ui/Toast'
|
||
import TransactionModal from '@/modals/crm/customers/TransactionModal'
|
||
import { fmtDateMedium } from '@/lib/formatters'
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
|
||
const FLOW_LABELS = { invoice: 'Invoice', payment: 'Payment', refund: 'Refund', credit: 'Credit' }
|
||
const PAYMENT_TYPE_LABELS = { cash: 'Cash', bank_transfer: 'Bank Transfer', card: 'Card', paypal: 'PayPal' }
|
||
const CATEGORY_LABELS = { full_payment: 'Full Payment', advance: 'Advance', installment: 'Installment' }
|
||
|
||
const ORDER_STATUS_LABELS = {
|
||
negotiating: 'Negotiating', awaiting_quotation: 'Awaiting Quotation',
|
||
awaiting_customer_confirmation: 'Awaiting Confirmation', awaiting_fulfilment: 'Accepted - Waiting',
|
||
awaiting_payment: 'Awaiting Payment', manufacturing: 'Manufacturing',
|
||
shipped: 'Shipped', installed: 'Installed', declined: 'Declined', complete: 'Complete',
|
||
}
|
||
|
||
const TERMINAL = new Set(['declined', 'complete'])
|
||
|
||
const FLOW_VARIANT = { payment: 'success', invoice: 'info', refund: 'warning', credit: 'neutral' }
|
||
|
||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
// Format as European currency: 12500800.70 → "€12.500.800,70"
|
||
function fmtEuro(amount, currency = 'EUR') {
|
||
const n = Number(amount || 0)
|
||
const [intPart, decPart] = n.toFixed(2).split('.')
|
||
const intFormatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
|
||
const suffix = currency && currency !== 'EUR' ? ` ${currency}` : ''
|
||
return `€${intFormatted},${decPart}${suffix}`
|
||
}
|
||
|
||
function fmtDate(iso) { return fmtDateMedium(iso) }
|
||
|
||
// ─── Stat Box Icons ───────────────────────────────────────────────────────────
|
||
|
||
const InvoiceIcon = ({ size = 20 }) => (
|
||
<svg width={size} height={size} viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M4.5 6.99542H4V7.99542H4.5V6.99542ZM10.5 7.99542H11V6.99542H10.5V7.99542ZM4.5 9.5H4V10H4.5V9.5ZM10.5 9.5V10H11V9.5H10.5ZM4.5 5.5V5H4V5.5H4.5ZM10.5 5.5H11V5H10.5V5.5ZM13.5 3.5H14V3.29289L13.8536 3.14645L13.5 3.5ZM10.5 0.5L10.8536 0.146447L10.7071 0H10.5V0.5ZM4.5 7.99542H10.5V6.99542H4.5V7.99542ZM4.5 10H10.5V9H4.5V10ZM4.5 6H10.5V5H4.5V6ZM12.5 14H2.5V15H12.5V14ZM2 13.5V1.5H1V13.5H2ZM13 3.5V13.5H14V3.5H13ZM2.5 1H10.5V0H2.5V1ZM10.1464 0.853553L13.1464 3.85355L13.8536 3.14645L10.8536 0.146447L10.1464 0.853553ZM2.5 14C2.22386 14 2 13.7761 2 13.5H1C1 14.3284 1.67157 15 2.5 15V14ZM12.5 15C13.3284 15 14 14.3284 14 13.5H13C13 13.7761 12.7761 14 12.5 14V15ZM2 1.5C2 1.22386 2.22386 1 2.5 1V0C1.67157 0 1 0.671574 1 1.5H2ZM4 5.5V9.5H5V5.5H4ZM7 5.5V9.5H8V5.5H7ZM10 5.5V9.5H11V5.5H10ZM4 4H7V3H4V4ZM8 12H11V11H8V12Z" fill="currentColor"/>
|
||
</svg>
|
||
)
|
||
|
||
const MoneyIcon = ({ size = 20 }) => (
|
||
<svg width={size} height={size} viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M432,7.938H80c-44.172,0-80,35.813-80,80c0,44.172,35.828,80,80,80h24v-48c-17.641,0-32-14.359-32-32c0-17.656,14.359-32,32-32h304c17.656,0,32,14.344,32,32c0,17.641-14.344,32-32,32v48h24c44.188,0,80-35.828,80-80C512,43.75,476.188,7.938,432,7.938z"/>
|
||
<path d="M120,504.063h272v-432H120V504.063z M152,147.25c23.453-2.344,42.297-20.219,46.188-43.188h115.656c3.875,22.969,22.703,40.844,46.156,43.188v92.938c-20.375-30.016-59.25-50.313-104-50.313c-44.734,0-83.625,20.281-104,50.297V147.25z M273.844,266.531v53.5l-15.969-3.313v-54.516c-0.906-0.063-1.844-0.109-2.75-0.109c-2.297,0-4.609,0.203-6.953,0.594v52.063l-15.969-3.281v-42.563c-12.063,7.75-19.375,20.969-19.375,35.469c0,4.906,0.906,9.859,2.688,14.656l1.438,3.844h-22.469l-0.563-2.109c-1.453-5.391-2.188-10.922-2.188-16.391c0-26.094,16.188-49.563,40.469-59.016v-13.5h15.969v9.578c2.453-0.297,4.719-0.438,6.953-0.438c0.906,0,1.844,0.031,2.75,0.063v-9.203h15.969v12.047c26.391,8.188,44.641,32.734,44.641,60.469c0,7.375-1.297,14.594-3.828,21.531l-0.828,2.297l-21.156-4.313l1.359-3.219c2.219-5.266,3.344-10.75,3.344-16.297C297.375,288.344,288.078,273.625,273.844,266.531z M152,335.953c20.375,30.016,59.266,50.297,104,50.297c44.75,0,83.625-20.281,104-50.313v92.906c-23.453,2.344-42.281,20.25-46.156,43.219H198.172c-3.875-22.969-22.719-40.875-46.172-43.219V335.953z"/>
|
||
<circle cx="256" cy="157.188" r="13.75"/>
|
||
<circle cx="256" cy="418.938" r="13.75"/>
|
||
</svg>
|
||
)
|
||
|
||
const BalanceDueIcon = ({ size = 20 }) => (
|
||
<svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="m 7 0 c -0.554688 0 -1 0.445312 -1 1 h -2 c -1.644531 0 -3 1.355469 -3 3 v 9 c 0 1.644531 1.355469 3 3 3 h 2 c 0.550781 0 1 -0.449219 1 -1 s -0.449219 -1 -1 -1 h -2 c -0.570312 0 -1 -0.429688 -1 -1 v -9 c 0 -0.570312 0.429688 -1 1 -1 h 1 v 1 c 0 0.554688 0.445312 1 1 1 h 4 c 0.554688 0 1 -0.445312 1 -1 v -1 h 1 c 0.570312 0 1 0.429688 1 1 v 2 c 0 0.550781 0.449219 1 1 1 s 1 -0.449219 1 -1 v -2 c 0 -1.644531 -1.355469 -3 -3 -3 h -2 c 0 -0.554688 -0.445312 -1 -1 -1 z m 0 0"/>
|
||
<path d="m 8.875 8 c -0.492188 0 -0.875 0.382812 -0.875 0.875 v 6.25 c 0 0.492188 0.382812 0.875 0.875 0.875 h 6.25 c 0.492188 0 0.875 -0.382812 0.875 -0.875 v -6.25 c 0 -0.492188 -0.382812 -0.875 -0.875 -0.875 z m 2.125 1 h 2 v 2.5 s 0 0.5 -0.5 0.5 h -1 c -0.5 0 -0.5 -0.5 -0.5 -0.5 z m 0.5 4 h 1 c 0.277344 0 0.5 0.222656 0.5 0.5 v 1 c 0 0.277344 -0.222656 0.5 -0.5 0.5 h -1 c -0.277344 0 -0.5 -0.222656 -0.5 -0.5 v -1 c 0 -0.277344 0.222656 -0.5 0.5 -0.5 z m 0 0"/>
|
||
</svg>
|
||
)
|
||
|
||
const TotalOrdersIcon = ({ size = 20 }) => (
|
||
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M12 13H15M12 16H20M12 20H20M12 24H20M21 7V2C21 1.447 20.553 1 20 1H2C1.447 1 1 1.447 1 2V24C1 24 1 25 2 25H3M26 27H30C30.553 27 31 26.553 31 26V4C31 3.447 30.553 3 30 3H24M26 30C26 30.553 25.553 31 25 31H7C6.447 31 6 30.553 6 30V8C6 7.447 6.447 7 7 7H25C25.553 7 26 7.447 26 8V30Z"/>
|
||
</svg>
|
||
)
|
||
|
||
const PaymentStatusIcon = ({ size = 20 }) => (
|
||
<svg width={size} height={size} viewBox="0 0 512 512" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M432.891,288.344c-5.469,3.219-11.828,5.125-18.641,5.125h-59.375c-20.266,0-36.766-16.5-36.766-36.781c0-5.672,1.328-11.172,3.766-16.109c-2.438-4.938-3.766-10.438-3.766-16.125c0-5.672,1.328-11.172,3.766-16.109c-2.438-4.938-3.766-10.438-3.766-16.109c0-5.688,1.328-11.188,3.766-16.125c-2.422-4.938-3.766-10.438-3.766-16.125c0-3.969,0.641-7.781,1.813-11.359h-36.75c-1.703,18.797-7.984,47.141-28.594,71.813c-22.453,26.844-55.891,42.531-99.313,46.813c-14.125,27.375-24.203,56.891-24.203,86.625c0,87.328,70.797,158.125,158.109,158.125c87.328,0,158.125-70.797,158.125-158.125C447.297,331.625,441.625,309.531,432.891,288.344z M322.828,346.844l-3.641-1.516c-5.969-2.531-12.188-3.797-18.469-3.797c-18.188,0-34.891,10.531-42.891,26.688h60.609l-3.734,18.094h-61.797c-0.078,1.031-0.125,2.078-0.125,3.125c0,2.594,0.219,5.219,0.672,7.875h59.016l-3.719,18.094H260.5c8.797,13.688,23.766,21.969,40.219,21.969c5.563,0,11.156-1.016,16.609-3.063l4.328-1.609v25.484l-2.375,0.625c-6.109,1.656-12.344,2.484-18.563,2.484c-29.594,0-56.219-18.344-66.922-45.891H218.5v-18.094h10.844c-0.328-2.781-0.484-5.359-0.484-7.875c0-1.031,0.031-2.094,0.078-3.125H218.5v-18.094h13.641c9.281-29.938,37.125-50.625,68.578-50.625c8.344,0,16.547,1.469,24.391,4.328l2.609,0.953L322.828,346.844z"/>
|
||
<path d="M335.859,68.875c0,0,4.656-12.469,29.734-28.297c24.063-15.172,5.594-36.141-29.734-28.422C311.469,17.469,319.75,0,285.547,0s-25.922,17.469-50.313,12.156c-35.328-7.719-53.797,13.25-29.734,28.422c25.078,15.828,29.734,28.297,29.734,28.297H335.859z"/>
|
||
<path d="M432.063,224.453c0-7.141-4.234-13.281-10.328-16.109c6.094-2.844,10.344-8.969,10.344-16.109c0-7.156-4.25-13.297-10.344-16.125c6.078-2.828,10.328-8.953,10.328-16.125c0-9.828-7.969-17.797-17.813-17.797h-59.375c-9.828,0-17.813,7.969-17.813,17.797c0,7.172,4.25,13.297,10.344,16.125c-6.094,2.828-10.344,8.969-10.344,16.125c0,7.141,4.25,13.266,10.344,16.109c-6.094,2.828-10.344,8.969-10.344,16.109c0,7.156,4.25,13.297,10.344,16.109c-6.094,2.844-10.344,8.969-10.344,16.125c0,9.844,7.984,17.813,17.813,17.813h59.375c9.828,0,17.813-7.969,17.813-17.813c0-7.156-4.25-13.281-10.328-16.109C427.813,237.75,432.063,231.609,432.063,224.453z"/>
|
||
<path d="M346.203,89.641c-20,0-113.25,0-113.25,0c-48.969,0-62.656,38.328-100.578,38.328v121.641l16.578-0.766c126.688-9.813,115.625-119.188,115.625-119.188s31.063,0,71.094,0C375.688,129.656,377.797,89.641,346.203,89.641z"/>
|
||
<rect x="64.703" y="113.75" width="50.563" height="164.281"/>
|
||
</svg>
|
||
)
|
||
|
||
// ─── Stat Box ─────────────────────────────────────────────────────────────────
|
||
|
||
function StatBox({ label, value, accentColor, note, icon: IconComponent }) {
|
||
return (
|
||
<div style={{
|
||
backgroundColor: 'var(--color-bg-elevated)',
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-lg)',
|
||
padding: 'var(--space-4)',
|
||
paddingLeft: 'var(--space-5)',
|
||
display: 'flex',
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 'var(--space-4)',
|
||
}}>
|
||
{IconComponent && (
|
||
<div style={{
|
||
color: accentColor || 'var(--color-text-muted)',
|
||
flexShrink: 0,
|
||
opacity: 0.8,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
}}>
|
||
<IconComponent size={40} />
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>
|
||
<span className="card-field-label">{label}</span>
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xl)',
|
||
fontWeight: 'var(--font-weight-bold)',
|
||
color: accentColor || 'var(--color-text-primary)',
|
||
fontFamily: 'var(--font-family-mono)',
|
||
lineHeight: 1.2,
|
||
}}>
|
||
{value}
|
||
</span>
|
||
{note && (
|
||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{note}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||
|
||
export default function FinancialsTab({ customer, orders, canEdit, onCustomerUpdated, onReloadOrders }) {
|
||
const { toast } = useToast()
|
||
|
||
const [showTxnModal, setShowTxnModal] = useState(false)
|
||
const [editTxnIndex, setEditTxnIndex] = useState(null)
|
||
const [deleteTxnIndex, setDeleteTxnIndex] = useState(null)
|
||
const [deleting, setDeleting] = useState(false)
|
||
|
||
const txns = [...(customer.transaction_history || [])].sort(
|
||
(a, b) => (b.date || '').localeCompare(a.date || '')
|
||
)
|
||
|
||
// Overall financial totals
|
||
const totalInvoiced = (customer.transaction_history || [])
|
||
.filter(t => t.flow === 'invoice')
|
||
.reduce((s, t) => s + (t.amount || 0), 0)
|
||
const totalPaid = (customer.transaction_history || [])
|
||
.filter(t => t.flow === 'payment')
|
||
.reduce((s, t) => s + (t.amount || 0), 0)
|
||
const outstanding = totalInvoiced - totalPaid
|
||
|
||
const activeOrders = (orders || []).filter(o => !TERMINAL.has(o.status))
|
||
|
||
const handleDelete = async () => {
|
||
setDeleting(true)
|
||
try {
|
||
const updated = await api.delete(`/crm/customers/${customer.id}/transactions/${deleteTxnIndex}`)
|
||
onCustomerUpdated?.(updated)
|
||
setDeleteTxnIndex(null)
|
||
toast.success('Deleted', 'Transaction removed.')
|
||
} catch (err) {
|
||
toast.danger('Error', err.message || 'Failed to delete transaction.')
|
||
} finally {
|
||
setDeleting(false)
|
||
}
|
||
}
|
||
|
||
const openAdd = () => { setEditTxnIndex(null); setShowTxnModal(true) }
|
||
const openEdit = (origIdx) => { setEditTxnIndex(origIdx); setShowTxnModal(true) }
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
|
||
|
||
{/* ── Overall Financial Status ──────────────────────────────────────── */}
|
||
<Card
|
||
title="Overall Financial Status"
|
||
style={{
|
||
background: 'linear-gradient(135deg, rgba(128, 131, 255, 0.18) 0%, rgba(192, 193, 255, 0.07) 100%)',
|
||
}}
|
||
>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))', gap: 'var(--space-4)' }}>
|
||
<StatBox label="Total Invoiced" value={fmtEuro(totalInvoiced)} accentColor="var(--color-text-primary)" icon={InvoiceIcon} />
|
||
<StatBox
|
||
label="Total Received"
|
||
value={fmtEuro(totalPaid)}
|
||
accentColor="var(--color-success)"
|
||
icon={MoneyIcon}
|
||
/>
|
||
<StatBox
|
||
label="Balance Due"
|
||
value={fmtEuro(outstanding)}
|
||
accentColor={outstanding > 0 ? 'var(--color-warning)' : 'var(--color-success)'}
|
||
icon={BalanceDueIcon}
|
||
/>
|
||
<StatBox
|
||
label="Total Orders"
|
||
value={(orders || []).length}
|
||
accentColor="var(--color-text-primary)"
|
||
icon={TotalOrdersIcon}
|
||
/>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* ── Active Orders Payment Status ──────────────────────────────────── */}
|
||
{activeOrders.length > 0 && (
|
||
<Card title="Active Orders — Payment Status">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
|
||
{activeOrders.map((order, orderIdx) => {
|
||
const ps = order.payment_status || {}
|
||
return (
|
||
<div key={order.id}>
|
||
{/* Order label row */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 'var(--space-3)',
|
||
marginBottom: 'var(--space-3)',
|
||
flexWrap: 'wrap',
|
||
}}>
|
||
<span style={{
|
||
fontFamily: 'var(--font-family-mono)',
|
||
fontSize: 'var(--font-size-sm)',
|
||
color: 'var(--color-text-primary)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
}}>
|
||
{order.order_number}
|
||
</span>
|
||
{order.title && (
|
||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||
— {order.title}
|
||
</span>
|
||
)}
|
||
<span style={{
|
||
fontSize: 'var(--font-size-xs)',
|
||
color: 'var(--color-text-muted)',
|
||
backgroundColor: 'var(--color-bg-elevated)',
|
||
border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-full)',
|
||
padding: '2px var(--space-2)',
|
||
}}>
|
||
{ORDER_STATUS_LABELS[order.status] || order.status}
|
||
</span>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 'var(--space-3)' }}>
|
||
<StatBox label="Required" value={fmtEuro(ps.required_amount)} accentColor="var(--color-text-primary)" icon={InvoiceIcon} />
|
||
<StatBox label="Received" value={fmtEuro(ps.received_amount)} accentColor="var(--color-success)" icon={MoneyIcon} />
|
||
<StatBox
|
||
label="Balance Due"
|
||
value={fmtEuro(ps.balance_due)}
|
||
accentColor={(ps.balance_due || 0) > 0 ? 'var(--color-warning)' : 'var(--color-success)'}
|
||
icon={BalanceDueIcon}
|
||
/>
|
||
<StatBox
|
||
label="Payment"
|
||
value={ps.payment_complete ? 'Complete' : (ps.required_amount || 0) === 0 ? 'No Invoice' : 'Pending'}
|
||
accentColor={ps.payment_complete ? 'var(--color-success)' : 'var(--color-text-muted)'}
|
||
icon={PaymentStatusIcon}
|
||
/>
|
||
</div>
|
||
|
||
{orderIdx < activeOrders.length - 1 && (
|
||
<div style={{ borderBottom: '1px solid var(--color-border)', marginTop: 'var(--space-6)' }} />
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ── Transaction History ───────────────────────────────────────────── */}
|
||
<Card
|
||
title="Transaction History"
|
||
subtitle={txns.length > 0 ? `${txns.length} record${txns.length !== 1 ? 's' : ''}` : undefined}
|
||
>
|
||
{/* Header action */}
|
||
{canEdit && (
|
||
<div style={{ marginBottom: 'var(--space-4)', display: 'flex', justifyContent: 'flex-end' }}>
|
||
<Button variant="primary" size="sm" onClick={openAdd}>
|
||
+ Add Transaction
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{txns.length === 0 ? (
|
||
<div style={{
|
||
padding: 'var(--space-10)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
gap: 'var(--space-2)',
|
||
color: 'var(--color-text-muted)',
|
||
}}>
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||
<path d="M2 10h20" />
|
||
</svg>
|
||
<span style={{ fontSize: 'var(--font-size-sm)' }}>No transactions recorded yet.</span>
|
||
</div>
|
||
) : (
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 700 }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||
{['Date', 'Flow', 'Amount', 'Method', 'Category', 'Order', 'Note', 'By', ''].map(h => (
|
||
<th key={h} style={{
|
||
padding: 'var(--space-2) var(--space-3)',
|
||
textAlign: 'left',
|
||
fontSize: 'var(--font-size-xs)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
color: 'var(--color-text-muted)',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 'var(--tracking-wide)',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{txns.map((t, i) => {
|
||
const origIdx = (customer.transaction_history || []).findIndex(
|
||
x => x.date === t.date && x.amount === t.amount && x.note === t.note && x.recorded_by === t.recorded_by
|
||
)
|
||
const linkedOrder = t.order_ref ? (orders || []).find(o => o.id === t.order_ref) : null
|
||
|
||
return (
|
||
<tr
|
||
key={i}
|
||
style={{ borderBottom: '1px solid var(--color-border)', transition: 'background 0.1s' }}
|
||
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
|
||
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
|
||
>
|
||
<td style={{ padding: 'var(--space-2) var(--space-3)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap', fontSize: 'var(--font-size-sm)' }}>
|
||
{fmtDate(t.date)}
|
||
</td>
|
||
<td style={{ padding: 'var(--space-2) var(--space-3)' }}>
|
||
<StatusBadge variant={FLOW_VARIANT[t.flow] || 'neutral'}>
|
||
{FLOW_LABELS[t.flow] || t.flow}
|
||
</StatusBadge>
|
||
</td>
|
||
<td style={{
|
||
padding: 'var(--space-2) var(--space-3)',
|
||
fontWeight: 'var(--font-weight-semibold)',
|
||
fontFamily: 'var(--font-family-mono)',
|
||
fontSize: 'var(--font-size-sm)',
|
||
color: t.flow === 'payment' ? 'var(--color-success)'
|
||
: t.flow === 'invoice' ? 'var(--color-text-primary)'
|
||
: 'var(--color-text-muted)',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{t.flow === 'payment' ? '+' : t.flow === 'invoice' ? '−' : ''}{fmtEuro(t.amount, t.currency)}
|
||
</td>
|
||
<td style={{ padding: 'var(--space-2) var(--space-3)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||
{PAYMENT_TYPE_LABELS[t.payment_type] || '—'}
|
||
</td>
|
||
<td style={{ padding: 'var(--space-2) var(--space-3)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||
{t.flow === 'invoice' ? '—' : (CATEGORY_LABELS[t.category] || t.category || '—')}
|
||
</td>
|
||
<td style={{ padding: 'var(--space-2) var(--space-3)' }}>
|
||
{linkedOrder ? (
|
||
<span style={{
|
||
fontFamily: 'var(--font-family-mono)',
|
||
fontSize: 'var(--font-size-xs)',
|
||
color: 'var(--color-text-accent)',
|
||
}}>
|
||
{linkedOrder.order_number}
|
||
</span>
|
||
) : t.order_ref ? (
|
||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||
{t.order_ref.slice(0, 8)}
|
||
</span>
|
||
) : (
|
||
<span style={{ color: 'var(--color-text-muted)' }}>—</span>
|
||
)}
|
||
</td>
|
||
<td style={{
|
||
padding: 'var(--space-2) var(--space-3)',
|
||
color: 'var(--color-text-muted)',
|
||
fontSize: 'var(--font-size-sm)',
|
||
maxWidth: 160,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}>
|
||
{t.note || '—'}
|
||
</td>
|
||
<td style={{ padding: 'var(--space-2) var(--space-3)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)', whiteSpace: 'nowrap' }}>
|
||
{t.recorded_by || '—'}
|
||
</td>
|
||
{canEdit && (
|
||
<td style={{ padding: 'var(--space-2) var(--space-3)', whiteSpace: 'nowrap' }}>
|
||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||
<Button variant="table-actions" size="sm" onClick={() => openEdit(origIdx)}>
|
||
Edit
|
||
</Button>
|
||
<Button variant="danger" size="sm" onClick={() => setDeleteTxnIndex(origIdx)}>
|
||
Delete
|
||
</Button>
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* ── Modals ─────────────────────────────────────────────────────────── */}
|
||
<TransactionModal
|
||
open={showTxnModal}
|
||
customerId={customer.id}
|
||
orders={orders || []}
|
||
editIndex={editTxnIndex}
|
||
initialData={
|
||
editTxnIndex !== null && editTxnIndex !== undefined
|
||
? (customer.transaction_history || [])[editTxnIndex]
|
||
: undefined
|
||
}
|
||
outstandingBalance={outstanding}
|
||
onClose={() => { setShowTxnModal(false); setEditTxnIndex(null) }}
|
||
onSaved={updated => { onCustomerUpdated?.(updated); onReloadOrders?.() }}
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
open={deleteTxnIndex !== null}
|
||
variant="danger"
|
||
title="Delete Transaction"
|
||
message="This transaction will be permanently removed. This cannot be undone."
|
||
confirmLabel={deleting ? 'Deleting…' : 'Delete'}
|
||
onConfirm={handleDelete}
|
||
onCancel={() => setDeleteTxnIndex(null)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|