ui fixers in 10/16
This commit is contained in:
@@ -1,14 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip } from "chart.js"
|
||||
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip, type Plugin } from "chart.js"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Line } from "react-chartjs-2"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
||||
import { DayEndReportDay } from "@/types/sales"
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
|
||||
|
||||
/** Phones plot only the most recent days — four lines over a long range are unreadable at that width. */
|
||||
const MOBILE_POINTS = 5
|
||||
|
||||
/** Space between the legend (the coloured dots) and the plot. Chart.js has no option for it, so the
|
||||
* legend box is made taller after it measures itself — the extra height sits below its items. */
|
||||
const LEGEND_GAP_PX = 16
|
||||
const legendGap: Plugin<"line"> = {
|
||||
id: "legendGap",
|
||||
beforeInit(chart) {
|
||||
const legend = chart.legend as (typeof chart.legend & { fit: () => void }) | undefined
|
||||
if (!legend) return
|
||||
const fit = legend.fit.bind(legend)
|
||||
legend.fit = function () {
|
||||
fit()
|
||||
this.height += LEGEND_GAP_PX
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/** An en space (U+2002) put before each legend label — widens the fixed dot-to-text gap. */
|
||||
const LEGEND_TEXT_GAP = String.fromCharCode(0x2002)
|
||||
|
||||
/** Short axis amounts for phones: 1,250,000 → 1.3M, 45,000 → 45K. Tooltips keep the full figure. */
|
||||
const compactAmount = (v: number) =>
|
||||
new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(v)
|
||||
|
||||
// Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped).
|
||||
const SERIES = {
|
||||
light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" },
|
||||
@@ -17,9 +44,12 @@ const SERIES = {
|
||||
|
||||
/** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared
|
||||
* index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */
|
||||
export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
|
||||
export function DayEndIncomeChart({ days: allDays }: { days: DayEndReportDay[] }) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isMobile = useIsMobile()
|
||||
const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light
|
||||
const days = isMobile ? allDays.slice(-MOBILE_POINTS) : allDays
|
||||
const trimmed = days.length < allDays.length
|
||||
const labels = days.map((d) => formatReportDate(d.date))
|
||||
|
||||
const line = (label: string, data: number[], color: string, width: number) => ({
|
||||
@@ -27,50 +57,84 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
|
||||
data,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
borderWidth: width,
|
||||
borderWidth: isMobile ? Math.max(1.5, width - 0.5) : width,
|
||||
pointRadius: days.length > 45 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
tension: 0.25,
|
||||
fill: false,
|
||||
})
|
||||
|
||||
const tickFont = { size: isMobile ? 10 : 12 }
|
||||
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<Line
|
||||
data={{
|
||||
labels,
|
||||
datasets: [
|
||||
line("Income", days.map((d) => d.income), c.income, 2.5),
|
||||
line("Sales", days.map((d) => d.totalSales), c.sales, 2),
|
||||
line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2),
|
||||
line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5),
|
||||
],
|
||||
}}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
legend: { position: "top", align: "start", labels: { color: c.text, usePointStyle: true, boxWidth: 8 } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const y = ctx.parsed.y ?? 0
|
||||
if (ctx.dataset.label === "Income")
|
||||
return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}`
|
||||
if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}`
|
||||
const p = profitOrLoss(y)
|
||||
return `${p.label}: ${p.amount}`
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{trimmed && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing the last {days.length} days — the table below lists every day.
|
||||
</p>
|
||||
)}
|
||||
<div className="h-64 w-full sm:h-72">
|
||||
<Line
|
||||
plugins={[legendGap]}
|
||||
data={{
|
||||
labels,
|
||||
datasets: [
|
||||
line("Income", days.map((d) => d.income), c.income, 2.5),
|
||||
line("Sales", days.map((d) => d.totalSales), c.sales, 2),
|
||||
line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2),
|
||||
line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5),
|
||||
],
|
||||
}}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
plugins: {
|
||||
// Legend on top (start-aligned on desktop, centred on phones) with small labels on phones.
|
||||
legend: {
|
||||
position: "top",
|
||||
align: isMobile ? "center" : "start",
|
||||
labels: {
|
||||
color: c.text,
|
||||
usePointStyle: true,
|
||||
boxWidth: isMobile ? 6 : 8,
|
||||
font: { size: isMobile ? 11 : 12 },
|
||||
// Chart.js fixes the dot-to-text gap at half the font size; a leading en space widens it.
|
||||
generateLabels: (chart) =>
|
||||
ChartJS.defaults.plugins.legend.labels.generateLabels(chart).map((item) => ({ ...item, text: `${LEGEND_TEXT_GAP}${item.text}` })),
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const y = ctx.parsed.y ?? 0
|
||||
if (ctx.dataset.label === "Income")
|
||||
return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}`
|
||||
if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}`
|
||||
const p = profitOrLoss(y)
|
||||
return `${p.label}: ${p.amount}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } },
|
||||
y: { grid: { color: c.grid }, ticks: { color: c.text, callback: (v) => formatAmount(Number(v)) } },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: isMobile ? MOBILE_POINTS : 10, font: tickFont },
|
||||
},
|
||||
y: {
|
||||
grid: { color: c.grid },
|
||||
ticks: {
|
||||
color: c.text,
|
||||
font: tickFont,
|
||||
maxTicksLimit: isMobile ? 5 : 8,
|
||||
callback: (v) => (isMobile ? compactAmount(Number(v)) : formatAmount(Number(v))),
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -147,9 +147,9 @@ function SerialPicker({
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={toAdd.length === 0}
|
||||
onClick={selectAll}
|
||||
title={toAdd.length < unselectedShown.length ? `Only ${units} serial${units === 1 ? "" : "s"} needed for this line` : undefined}
|
||||
title={toAdd.length < unselectedShown.length ? `Ticks up to the ${units} serial${units === 1 ? "" : "s"} this line needs` : undefined}
|
||||
>
|
||||
{toAdd.length > 0 && toAdd.length < unselectedShown.length ? `Select first ${toAdd.length}` : "Select all"}
|
||||
Select all
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto rounded-md border">
|
||||
|
||||
@@ -63,7 +63,10 @@ interface PaymentLinesEditorProps {
|
||||
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) {
|
||||
const paid = paymentLinesTotal(lines)
|
||||
const remaining = round2(balance - paid)
|
||||
const cols = methodOptions ? "grid-cols-[1fr_9rem_8rem_8rem]" : "grid-cols-[1fr_8rem_8rem]"
|
||||
// Phones: each line is a stacked card (type on top, method + amount side by side). From `sm` up
|
||||
// it becomes one row in a fixed-column grid. Full class strings so Tailwind can see them.
|
||||
const cols = methodOptions ? "sm:grid-cols-[1fr_9rem_8rem_8rem]" : "sm:grid-cols-[1fr_8rem_8rem]"
|
||||
const mobileLabel = "text-xs font-medium text-muted-foreground sm:hidden"
|
||||
|
||||
function updateLine(key: number, patch: Partial<PaymentLine>) {
|
||||
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
@@ -83,7 +86,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={`grid ${cols} gap-2 text-sm font-medium`}>
|
||||
<div className={`hidden gap-2 text-sm font-medium sm:grid ${cols}`}>
|
||||
<span>Payment type</span>
|
||||
{methodOptions && <span>Method</span>}
|
||||
<span>Paid amount</span>
|
||||
@@ -91,11 +94,15 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
||||
</div>
|
||||
|
||||
{lines.map((line, idx) => (
|
||||
<div key={line.key} className={`grid ${cols} items-start gap-2`}>
|
||||
<div>
|
||||
<div
|
||||
key={line.key}
|
||||
className={`grid grid-cols-2 items-start gap-2 rounded-lg border p-3 sm:rounded-none sm:border-0 sm:p-0 ${cols}`}
|
||||
>
|
||||
<div className="col-span-2 flex flex-col gap-1 sm:col-span-1">
|
||||
<span className={mobileLabel}>Payment type {lines.length > 1 ? idx + 1 : ""}</span>
|
||||
<Select<string> value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}>
|
||||
<SelectTrigger
|
||||
className="w-full text-base"
|
||||
className="h-11 w-full text-base sm:h-9"
|
||||
aria-label={`Payment type, line ${idx + 1}`}
|
||||
aria-invalid={!!errors[`account-${line.key}`]}
|
||||
>
|
||||
@@ -112,9 +119,10 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
||||
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
|
||||
</div>
|
||||
{methodOptions && (
|
||||
<div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={mobileLabel}>Method</span>
|
||||
<Select<string> value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}>
|
||||
<SelectTrigger className="w-full text-base" aria-label={`Payment method, line ${idx + 1}`}>
|
||||
<SelectTrigger className="h-11 w-full text-base sm:h-9" aria-label={`Payment method, line ${idx + 1}`}>
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -127,20 +135,22 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className={`flex flex-col gap-1 sm:col-span-1 ${methodOptions ? "" : "col-span-2"}`}>
|
||||
<span className={mobileLabel}>Paid amount</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
inputMode="decimal"
|
||||
value={line.amount}
|
||||
onChange={(e) => updateLine(line.key, { amount: e.target.value })}
|
||||
aria-label={`Paid amount, line ${idx + 1}`}
|
||||
aria-invalid={!!errors[`amount-${line.key}`]}
|
||||
className="text-right tabular-nums"
|
||||
className="h-11 text-right text-base tabular-nums sm:h-9"
|
||||
/>
|
||||
<FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="col-span-2 flex items-center justify-between gap-1 sm:col-span-1 sm:justify-start">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -148,12 +158,13 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
||||
onClick={() => onChange(lines.filter((l) => l.key !== line.key))}
|
||||
disabled={lines.length === 1}
|
||||
aria-label={`Remove line ${idx + 1}`}
|
||||
className="text-destructive hover:bg-destructive/10 disabled:text-muted-foreground"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
{/* Add sits on the last line itself, so a new split appears right below it. */}
|
||||
{idx === lines.length - 1 && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine} className="mt-1 whitespace-nowrap">
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine} className="h-10 whitespace-nowrap sm:mt-1 sm:h-8">
|
||||
<Plus className="size-4" />
|
||||
Add line
|
||||
</Button>
|
||||
@@ -163,7 +174,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
|
||||
))}
|
||||
|
||||
{/* Total is what's owed (fixed); Paid is what the lines add up to. */}
|
||||
<div className="ml-auto grid w-full max-w-64 grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm">
|
||||
<div className="grid w-full grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm sm:ml-auto sm:max-w-64">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="text-right font-semibold tabular-nums">{formatAmount(balance)}</span>
|
||||
<span className="text-muted-foreground">Paid amount</span>
|
||||
|
||||
@@ -140,13 +140,16 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<DialogTitle className="pr-8 leading-snug">
|
||||
Pay {ordered.length} invoices — {customerName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
|
||||
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
|
||||
|
||||
{draftCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "}
|
||||
@@ -154,12 +157,12 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border">
|
||||
<div className="max-h-48 shrink-0 overflow-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Invoice</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Date</th>
|
||||
<th className="hidden px-3 py-2 text-left font-medium sm:table-cell">Date</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Balance</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Applied</th>
|
||||
</tr>
|
||||
@@ -167,8 +170,8 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
|
||||
<tbody>
|
||||
{allocation.map(({ inv, applied }) => (
|
||||
<tr key={inv.salesInvoiceId} className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{inv.invoiceNo}</td>
|
||||
<td className="px-3 py-2">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
|
||||
<td className="px-3 py-2 font-medium whitespace-nowrap">{inv.invoiceNo}</td>
|
||||
<td className="hidden px-3 py-2 sm:table-cell">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(inv.totals.balanceAmount)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{applied > 0 ? formatAmount(applied) : "—"}</td>
|
||||
</tr>
|
||||
@@ -220,11 +223,13 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : draftCount > 0 ? "Confirm & record payment" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -145,11 +145,14 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||
<DialogTitle className="pr-8 leading-snug">{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */}
|
||||
<div className="-mx-4 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
|
||||
@@ -198,11 +201,13 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
<Button variant="outline" className="h-11 sm:h-9" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
<Button className="h-11 sm:h-9" onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
Reference in New Issue
Block a user