ui fixers in 10/16

This commit is contained in:
2026-10-16 14:34:28 +05:30
parent 379ad204bd
commit e289b8430f
47 changed files with 1904 additions and 1046 deletions
@@ -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>