Spaces:
Runtime error
Units fix (frontend): unit-aware UI + rich analytics
Browse filesShared src/lib/unit.ts (step/min/keypad/decimals/label/quantize per unit). Unit-aware QtyStepper/NumberField (integer keypad for piece/dozen/packet), Qty formatter (kg 3dp, count integer w/ pc/dz/pkt label), RatePerUnit (₹X/unit), BillLinesTable units. Route/catalog/subscriptions/billing/customers thread the real unit; route sends quantize() not round1 (keeps kg precision, integers for counts). Rich analytics: revenue-primary, per-unit chips (never cross-unit sums), metric/unit/date/bucket/chart-type dropdowns, ApexCharts. Fix: litre quantize=2dp (preserves 0.25 L milk). Defaults to litre so existing milk screens unchanged. tsc+build green; full docker image serves PWA + per-unit analytics.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- frontend/PWA/src/api/types.ts +1 -1
- frontend/PWA/src/components/domain/BillLinesTable.tsx +3 -3
- frontend/PWA/src/components/domain/types.ts +5 -3
- frontend/PWA/src/components/fields/NumberField.tsx +33 -6
- frontend/PWA/src/components/fields/QtyStepper.tsx +39 -18
- frontend/PWA/src/components/format/Money.tsx +23 -0
- frontend/PWA/src/components/format/Qty.tsx +12 -8
- frontend/PWA/src/components/index.ts +2 -2
- frontend/PWA/src/features/analytics/DashboardPage.tsx +194 -87
- frontend/PWA/src/features/analytics/ReportsPage.tsx +103 -44
- frontend/PWA/src/features/analytics/UnitQtyChips.tsx +40 -0
- frontend/PWA/src/features/analytics/charts.tsx +222 -0
- frontend/PWA/src/features/analytics/controls.tsx +119 -0
- frontend/PWA/src/features/analytics/unitQty.ts +55 -0
- frontend/PWA/src/features/analytics/unitTypes.ts +45 -0
- frontend/PWA/src/features/billing/BillDetailPage.tsx +11 -7
- frontend/PWA/src/features/billing/components/BillListCard.tsx +63 -0
- frontend/PWA/src/features/billing/components/BillsListTab.tsx +6 -3
- frontend/PWA/src/features/billing/components/GenerateTab.tsx +3 -2
- frontend/PWA/src/features/billing/hooks/useGeneratePreview.ts +30 -6
- frontend/PWA/src/features/billing/lib/billLineUnit.ts +44 -0
- frontend/PWA/src/features/catalog/components/AddPriceSheet.tsx +26 -3
- frontend/PWA/src/features/catalog/components/PriceRow.tsx +8 -5
- frontend/PWA/src/features/catalog/components/ResolveRatePreview.tsx +6 -3
- frontend/PWA/src/features/catalog/pages/ProductPricesPage.tsx +9 -4
- frontend/PWA/src/features/catalog/unit.ts +23 -13
- frontend/PWA/src/features/customers/components/ConsumptionBreakdown.tsx +86 -0
- frontend/PWA/src/features/customers/components/SubscriptionFormSheet.tsx +19 -5
- frontend/PWA/src/features/customers/lib/units.ts +60 -0
- frontend/PWA/src/features/customers/pages/CustomerDetailPage.tsx +70 -24
- frontend/PWA/src/features/route/components/RecordDeliverySheet.tsx +20 -7
- frontend/PWA/src/features/route/components/RouteSection.tsx +4 -5
- frontend/PWA/src/features/route/components/SubmitConfirmSheet.tsx +14 -7
- frontend/PWA/src/features/route/hooks/useRouteDraft.ts +26 -5
- frontend/PWA/src/features/route/pages/RoutePage.tsx +4 -1
- frontend/PWA/src/lib/unit.ts +133 -0
- frontend/PWA/tsconfig.app.tsbuildinfo +1 -1
|
@@ -135,7 +135,7 @@ export interface Settings {
|
|
| 135 |
invoice_prefix: string;
|
| 136 |
}
|
| 137 |
|
| 138 |
-
export type ProductUnit = 'litre' | 'kg' | 'piece';
|
| 139 |
|
| 140 |
export interface Product {
|
| 141 |
id: number;
|
|
|
|
| 135 |
invoice_prefix: string;
|
| 136 |
}
|
| 137 |
|
| 138 |
+
export type ProductUnit = 'litre' | 'kg' | 'piece' | 'dozen' | 'packet';
|
| 139 |
|
| 140 |
export interface Product {
|
| 141 |
id: number;
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { Box, Divider, Stack, Typography } from '@mui/material';
|
| 2 |
-
import { Money } from '../format/Money';
|
| 3 |
import { Qty } from '../format/Qty';
|
| 4 |
import type { BillLine } from './types';
|
| 5 |
|
|
@@ -77,10 +77,10 @@ export function BillLinesTable({
|
|
| 77 |
{l.productName}
|
| 78 |
</Typography>
|
| 79 |
<Box sx={{ width: 64, textAlign: 'right' }}>
|
| 80 |
-
<Qty value={l.qty} unit={l.unit ?? '
|
| 81 |
</Box>
|
| 82 |
<Box sx={{ width: 72, textAlign: 'right' }}>
|
| 83 |
-
<
|
| 84 |
</Box>
|
| 85 |
<Box sx={{ width: 88, textAlign: 'right' }}>
|
| 86 |
<Money value={l.amount} component="span" />
|
|
|
|
| 1 |
import { Box, Divider, Stack, Typography } from '@mui/material';
|
| 2 |
+
import { Money, RatePerUnit } from '../format/Money';
|
| 3 |
import { Qty } from '../format/Qty';
|
| 4 |
import type { BillLine } from './types';
|
| 5 |
|
|
|
|
| 77 |
{l.productName}
|
| 78 |
</Typography>
|
| 79 |
<Box sx={{ width: 64, textAlign: 'right' }}>
|
| 80 |
+
<Qty value={l.qty} unit={l.unit ?? 'litre'} component="span" />
|
| 81 |
</Box>
|
| 82 |
<Box sx={{ width: 72, textAlign: 'right' }}>
|
| 83 |
+
<RatePerUnit value={l.rate} unit={l.unit ?? 'litre'} component="span" sx={{ fontSize: 13 }} />
|
| 84 |
</Box>
|
| 85 |
<Box sx={{ width: 88, textAlign: 'right' }}>
|
| 86 |
<Money value={l.amount} component="span" />
|
|
@@ -1,6 +1,8 @@
|
|
| 1 |
// Light presentational domain models for the shared cards/rows. Pages map API DTOs
|
| 2 |
// into these shapes; the library stays decoupled from exact backend field names.
|
| 3 |
|
|
|
|
|
|
|
| 4 |
export interface CustomerLite {
|
| 5 |
id: string;
|
| 6 |
name: string;
|
|
@@ -12,7 +14,7 @@ export interface CustomerLite {
|
|
| 12 |
export interface ProductLite {
|
| 13 |
id: string;
|
| 14 |
name: string;
|
| 15 |
-
unit?:
|
| 16 |
active: boolean;
|
| 17 |
}
|
| 18 |
|
|
@@ -24,7 +26,7 @@ export interface DeliveryRowData {
|
|
| 24 |
productName: string;
|
| 25 |
status: DeliveryStatus;
|
| 26 |
qty: number;
|
| 27 |
-
unit?:
|
| 28 |
rate?: number;
|
| 29 |
/** amount = qty*rate, precomputed by the page */
|
| 30 |
amount?: number;
|
|
@@ -38,7 +40,7 @@ export type BillStatus = 'paid' | 'partial' | 'unpaid';
|
|
| 38 |
export interface BillLine {
|
| 39 |
productName: string;
|
| 40 |
qty: number;
|
| 41 |
-
unit?:
|
| 42 |
rate: number;
|
| 43 |
amount: number;
|
| 44 |
}
|
|
|
|
| 1 |
// Light presentational domain models for the shared cards/rows. Pages map API DTOs
|
| 2 |
// into these shapes; the library stays decoupled from exact backend field names.
|
| 3 |
|
| 4 |
+
import type { UnitLike } from '@/lib/unit';
|
| 5 |
+
|
| 6 |
export interface CustomerLite {
|
| 7 |
id: string;
|
| 8 |
name: string;
|
|
|
|
| 14 |
export interface ProductLite {
|
| 15 |
id: string;
|
| 16 |
name: string;
|
| 17 |
+
unit?: UnitLike;
|
| 18 |
active: boolean;
|
| 19 |
}
|
| 20 |
|
|
|
|
| 26 |
productName: string;
|
| 27 |
status: DeliveryStatus;
|
| 28 |
qty: number;
|
| 29 |
+
unit?: UnitLike;
|
| 30 |
rate?: number;
|
| 31 |
/** amount = qty*rate, precomputed by the page */
|
| 32 |
amount?: number;
|
|
|
|
| 40 |
export interface BillLine {
|
| 41 |
productName: string;
|
| 42 |
qty: number;
|
| 43 |
+
unit?: UnitLike;
|
| 44 |
rate: number;
|
| 45 |
amount: number;
|
| 46 |
}
|
|
@@ -2,6 +2,7 @@ import { Controller, type FieldValues, type Path, type Control } from 'react-hoo
|
|
| 2 |
import { InputAdornment, TextField as MuiTextField } from '@mui/material';
|
| 3 |
import CurrencyRupee from '@mui/icons-material/CurrencyRupee';
|
| 4 |
import { KeyboardAwareField } from './KeyboardAwareField';
|
|
|
|
| 5 |
|
| 6 |
export interface NumberFieldProps<T extends FieldValues> {
|
| 7 |
name: Path<T>;
|
|
@@ -15,14 +16,23 @@ export interface NumberFieldProps<T extends FieldValues> {
|
|
| 15 |
required?: boolean;
|
| 16 |
/** money adornment ₹ (left) */
|
| 17 |
money?: boolean;
|
| 18 |
-
/** unit adornment e.g. 'L' (right) */
|
| 19 |
unit?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
/** select the whole value on focus (qty entry) */
|
| 21 |
selectAllOnFocus?: boolean;
|
| 22 |
}
|
| 23 |
|
| 24 |
-
// DESIGN_SYSTEM §8.2 / §7.2 —
|
| 25 |
-
//
|
|
|
|
| 26 |
export function NumberField<T extends FieldValues>({
|
| 27 |
name,
|
| 28 |
control,
|
|
@@ -35,8 +45,18 @@ export function NumberField<T extends FieldValues>({
|
|
| 35 |
required = false,
|
| 36 |
money = false,
|
| 37 |
unit,
|
|
|
|
|
|
|
| 38 |
selectAllOnFocus = false,
|
| 39 |
}: NumberFieldProps<T>) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
return (
|
| 41 |
<Controller
|
| 42 |
name={name}
|
|
@@ -48,21 +68,28 @@ export function NumberField<T extends FieldValues>({
|
|
| 48 |
value={field.value ?? ''}
|
| 49 |
onChange={(e) => {
|
| 50 |
const v = e.target.value;
|
|
|
|
|
|
|
| 51 |
field.onChange(v === '' ? '' : Number(v));
|
| 52 |
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
onFocus={(e) => {
|
| 54 |
-
if (selectAllOnFocus) e.target.select();
|
| 55 |
}}
|
| 56 |
label={label}
|
| 57 |
type="number"
|
| 58 |
-
inputMode=
|
| 59 |
disabled={disabled}
|
| 60 |
required={required}
|
| 61 |
error={!!fieldState.error}
|
| 62 |
helperText={fieldState.error?.message ?? helperText}
|
| 63 |
fullWidth
|
| 64 |
slotProps={{
|
| 65 |
-
htmlInput: { min, max, step, inputMode:
|
| 66 |
input: {
|
| 67 |
startAdornment: money ? (
|
| 68 |
<InputAdornment position="start">
|
|
|
|
| 2 |
import { InputAdornment, TextField as MuiTextField } from '@mui/material';
|
| 3 |
import CurrencyRupee from '@mui/icons-material/CurrencyRupee';
|
| 4 |
import { KeyboardAwareField } from './KeyboardAwareField';
|
| 5 |
+
import { type UnitLike, isCountUnit, unitInputMode, unitPattern } from '@/lib/unit';
|
| 6 |
|
| 7 |
export interface NumberFieldProps<T extends FieldValues> {
|
| 8 |
name: Path<T>;
|
|
|
|
| 16 |
required?: boolean;
|
| 17 |
/** money adornment ₹ (left) */
|
| 18 |
money?: boolean;
|
| 19 |
+
/** unit adornment text e.g. 'L' / 'kg' / 'pc' (right) — purely visual */
|
| 20 |
unit?: string;
|
| 21 |
+
/**
|
| 22 |
+
* Product unit driving the keypad: count units (piece/dozen/packet) raise the
|
| 23 |
+
* integer-only pad and block the decimal point; weight/volume raise the decimal pad.
|
| 24 |
+
* Takes precedence over {@link integerOnly}.
|
| 25 |
+
*/
|
| 26 |
+
qtyUnit?: UnitLike;
|
| 27 |
+
/** force integer-only entry (numeric keypad, no decimal) without a unit */
|
| 28 |
+
integerOnly?: boolean;
|
| 29 |
/** select the whole value on focus (qty entry) */
|
| 30 |
selectAllOnFocus?: boolean;
|
| 31 |
}
|
| 32 |
|
| 33 |
+
// DESIGN_SYSTEM §8.2 / §7.2 — numeric field (qty / amount / rate). Defaults to the
|
| 34 |
+
// decimal keypad (milk is fractional 0.5/1.5 L). When `qtyUnit` is a count unit (or
|
| 35 |
+
// `integerOnly`), it raises the numeric keypad and blocks '.' so eggs/packs stay whole.
|
| 36 |
export function NumberField<T extends FieldValues>({
|
| 37 |
name,
|
| 38 |
control,
|
|
|
|
| 45 |
required = false,
|
| 46 |
money = false,
|
| 47 |
unit,
|
| 48 |
+
qtyUnit,
|
| 49 |
+
integerOnly = false,
|
| 50 |
selectAllOnFocus = false,
|
| 51 |
}: NumberFieldProps<T>) {
|
| 52 |
+
const intOnly = integerOnly || (qtyUnit !== undefined && isCountUnit(qtyUnit));
|
| 53 |
+
const mode: 'numeric' | 'decimal' = intOnly
|
| 54 |
+
? 'numeric'
|
| 55 |
+
: qtyUnit !== undefined
|
| 56 |
+
? unitInputMode(qtyUnit)
|
| 57 |
+
: 'decimal';
|
| 58 |
+
const pattern = intOnly ? '[0-9]*' : qtyUnit !== undefined ? unitPattern(qtyUnit) : '[0-9]*[.]?[0-9]*';
|
| 59 |
+
|
| 60 |
return (
|
| 61 |
<Controller
|
| 62 |
name={name}
|
|
|
|
| 68 |
value={field.value ?? ''}
|
| 69 |
onChange={(e) => {
|
| 70 |
const v = e.target.value;
|
| 71 |
+
// Block any non-digit (incl. '.') for integer-only / count units.
|
| 72 |
+
if (intOnly && v !== '' && !/^\d*$/.test(v)) return;
|
| 73 |
field.onChange(v === '' ? '' : Number(v));
|
| 74 |
}}
|
| 75 |
+
onKeyDown={(e) => {
|
| 76 |
+
if (intOnly && (e.key === '.' || e.key === ',' || e.key === 'e' || e.key === '-')) {
|
| 77 |
+
e.preventDefault();
|
| 78 |
+
}
|
| 79 |
+
}}
|
| 80 |
onFocus={(e) => {
|
| 81 |
+
if (selectAllOnFocus) (e.target as HTMLInputElement).select();
|
| 82 |
}}
|
| 83 |
label={label}
|
| 84 |
type="number"
|
| 85 |
+
inputMode={mode}
|
| 86 |
disabled={disabled}
|
| 87 |
required={required}
|
| 88 |
error={!!fieldState.error}
|
| 89 |
helperText={fieldState.error?.message ?? helperText}
|
| 90 |
fullWidth
|
| 91 |
slotProps={{
|
| 92 |
+
htmlInput: { min, max, step: intOnly ? 1 : step, inputMode: mode, pattern },
|
| 93 |
input: {
|
| 94 |
startAdornment: money ? (
|
| 95 |
<InputAdornment position="start">
|
|
@@ -1,39 +1,54 @@
|
|
| 1 |
import { Box, IconButton, InputBase } from '@mui/material';
|
| 2 |
import Add from '@mui/icons-material/Add';
|
| 3 |
import Remove from '@mui/icons-material/Remove';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export interface QtyStepperProps {
|
| 6 |
value: number;
|
| 7 |
onChange: (value: number) => void;
|
|
|
|
| 8 |
step?: number;
|
| 9 |
min?: number;
|
| 10 |
max?: number;
|
| 11 |
-
unit
|
|
|
|
| 12 |
disabled?: boolean;
|
| 13 |
}
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
//
|
| 18 |
-
// 44px tap targets. Tapping −/+ commits the value (no lingering keyboard).
|
| 19 |
export function QtyStepper({
|
| 20 |
value,
|
| 21 |
onChange,
|
| 22 |
-
step
|
| 23 |
-
min
|
| 24 |
max,
|
| 25 |
-
unit = '
|
| 26 |
disabled = false,
|
| 27 |
}: QtyStepperProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
const clamp = (n: number) => {
|
| 29 |
let v = n;
|
| 30 |
-
|
| 31 |
if (max !== undefined) v = Math.min(max, v);
|
| 32 |
-
return
|
| 33 |
};
|
| 34 |
|
| 35 |
-
const dec = () => onChange(clamp(value -
|
| 36 |
-
const inc = () => onChange(clamp(value +
|
| 37 |
|
| 38 |
return (
|
| 39 |
<Box
|
|
@@ -53,18 +68,24 @@ export function QtyStepper({
|
|
| 53 |
<InputBase
|
| 54 |
value={value}
|
| 55 |
onChange={(e) => {
|
| 56 |
-
const
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}}
|
| 59 |
inputProps={{
|
| 60 |
-
inputMode:
|
| 61 |
-
pattern:
|
| 62 |
style: { textAlign: 'center', width: 44, fontVariantNumeric: 'tabular-nums', fontSize: 16 },
|
| 63 |
-
'aria-label': `Quantity in ${unit}`,
|
| 64 |
}}
|
| 65 |
/>
|
| 66 |
<Box component="span" sx={{ pr: 0.5, color: 'text.secondary', fontSize: 14 }}>
|
| 67 |
-
{unit}
|
| 68 |
</Box>
|
| 69 |
<IconButton aria-label="Increase quantity" onClick={inc} sx={{ width: 44, height: 44, borderRadius: 0 }}>
|
| 70 |
<Add fontSize="small" />
|
|
|
|
| 1 |
import { Box, IconButton, InputBase } from '@mui/material';
|
| 2 |
import Add from '@mui/icons-material/Add';
|
| 3 |
import Remove from '@mui/icons-material/Remove';
|
| 4 |
+
import {
|
| 5 |
+
type UnitLike,
|
| 6 |
+
isCountUnit,
|
| 7 |
+
quantize,
|
| 8 |
+
unitInputMode,
|
| 9 |
+
unitLabel,
|
| 10 |
+
unitMin,
|
| 11 |
+
unitPattern,
|
| 12 |
+
unitStep,
|
| 13 |
+
} from '@/lib/unit';
|
| 14 |
|
| 15 |
export interface QtyStepperProps {
|
| 16 |
value: number;
|
| 17 |
onChange: (value: number) => void;
|
| 18 |
+
/** explicit step override; defaults to the unit's step (litre 0.5 / kg 0.25 / count 1) */
|
| 19 |
step?: number;
|
| 20 |
min?: number;
|
| 21 |
max?: number;
|
| 22 |
+
/** product unit — drives step/min/label/inputMode/quantize. Defaults to litre. */
|
| 23 |
+
unit?: UnitLike;
|
| 24 |
disabled?: boolean;
|
| 25 |
}
|
| 26 |
|
| 27 |
+
// DESIGN_SYSTEM §8.2 — −/value/+ with inline numeric. Unit-aware: litre steps 0.5,
|
| 28 |
+
// kg steps 0.25 (gram precision survives), count units (piece/dozen/packet) step by 1
|
| 29 |
+
// and accept whole numbers only (no decimal key). 44px tap targets. Tapping −/+ commits.
|
|
|
|
| 30 |
export function QtyStepper({
|
| 31 |
value,
|
| 32 |
onChange,
|
| 33 |
+
step,
|
| 34 |
+
min,
|
| 35 |
max,
|
| 36 |
+
unit = 'litre',
|
| 37 |
disabled = false,
|
| 38 |
}: QtyStepperProps) {
|
| 39 |
+
const count = isCountUnit(unit);
|
| 40 |
+
const stepValue = step ?? unitStep(unit);
|
| 41 |
+
const minValue = min ?? unitMin(unit);
|
| 42 |
+
|
| 43 |
const clamp = (n: number) => {
|
| 44 |
let v = n;
|
| 45 |
+
v = Math.max(minValue, v);
|
| 46 |
if (max !== undefined) v = Math.min(max, v);
|
| 47 |
+
return quantize(v, unit);
|
| 48 |
};
|
| 49 |
|
| 50 |
+
const dec = () => onChange(clamp(value - stepValue));
|
| 51 |
+
const inc = () => onChange(clamp(value + stepValue));
|
| 52 |
|
| 53 |
return (
|
| 54 |
<Box
|
|
|
|
| 68 |
<InputBase
|
| 69 |
value={value}
|
| 70 |
onChange={(e) => {
|
| 71 |
+
const raw = e.target.value;
|
| 72 |
+
if (raw === '') {
|
| 73 |
+
onChange(minValue);
|
| 74 |
+
return;
|
| 75 |
+
}
|
| 76 |
+
// For count units, ignore any non-digit (blocks a typed '.').
|
| 77 |
+
if (count && !/^\d*$/.test(raw)) return;
|
| 78 |
+
onChange(clamp(Number(raw)));
|
| 79 |
}}
|
| 80 |
inputProps={{
|
| 81 |
+
inputMode: unitInputMode(unit),
|
| 82 |
+
pattern: unitPattern(unit),
|
| 83 |
style: { textAlign: 'center', width: 44, fontVariantNumeric: 'tabular-nums', fontSize: 16 },
|
| 84 |
+
'aria-label': `Quantity in ${unitLabel(unit)}`,
|
| 85 |
}}
|
| 86 |
/>
|
| 87 |
<Box component="span" sx={{ pr: 0.5, color: 'text.secondary', fontSize: 14 }}>
|
| 88 |
+
{unitLabel(unit)}
|
| 89 |
</Box>
|
| 90 |
<IconButton aria-label="Increase quantity" onClick={inc} sx={{ width: 44, height: 44, borderRadius: 0 }}>
|
| 91 |
<Add fontSize="small" />
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
| 2 |
import { Box, type BoxProps } from '@mui/material';
|
| 3 |
import { useReducedMotion } from '@/hooks/useReducedMotion';
|
| 4 |
import { dur } from '@/theme/motion';
|
|
|
|
| 5 |
|
| 6 |
export interface MoneyProps extends Omit<BoxProps, 'children'> {
|
| 7 |
/** value in rupees (not paise) */
|
|
@@ -73,3 +74,25 @@ export function Money({ value, countUp = false, sign = false, sx, ...rest }: Mon
|
|
| 73 |
</Box>
|
| 74 |
);
|
| 75 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import { Box, type BoxProps } from '@mui/material';
|
| 3 |
import { useReducedMotion } from '@/hooks/useReducedMotion';
|
| 4 |
import { dur } from '@/theme/motion';
|
| 5 |
+
import { type UnitLike, unitLabel } from '@/lib/unit';
|
| 6 |
|
| 7 |
export interface MoneyProps extends Omit<BoxProps, 'children'> {
|
| 8 |
/** value in rupees (not paise) */
|
|
|
|
| 74 |
</Box>
|
| 75 |
);
|
| 76 |
}
|
| 77 |
+
|
| 78 |
+
export interface RatePerUnitProps extends Omit<BoxProps, 'children'> {
|
| 79 |
+
/** rate in rupees per unit */
|
| 80 |
+
value: number;
|
| 81 |
+
/** product unit (canonical or legacy token) — rendered as the short label */
|
| 82 |
+
unit?: UnitLike;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// DESIGN_SYSTEM §8.4 — a price expressed per unit, e.g. "₹600/kg", "₹7/pc", "₹56/L".
|
| 86 |
+
// Uses the same en-IN money formatting as <Money> then appends "/<unitLabel>".
|
| 87 |
+
export function RatePerUnit({ value, unit = 'litre', sx, ...rest }: RatePerUnitProps) {
|
| 88 |
+
return (
|
| 89 |
+
<Box
|
| 90 |
+
component="span"
|
| 91 |
+
aria-label={`${format(value)} per ${unitLabel(unit)}`}
|
| 92 |
+
sx={[{ fontVariantNumeric: 'tabular-nums' }, ...(Array.isArray(sx) ? sx : [sx])]}
|
| 93 |
+
{...rest}
|
| 94 |
+
>
|
| 95 |
+
{format(value)}/{unitLabel(unit)}
|
| 96 |
+
</Box>
|
| 97 |
+
);
|
| 98 |
+
}
|
|
@@ -1,19 +1,23 @@
|
|
| 1 |
import { Box, type BoxProps } from '@mui/material';
|
|
|
|
| 2 |
|
|
|
|
| 3 |
export type QtyUnit = 'L' | 'kg' | 'u';
|
| 4 |
|
| 5 |
export interface QtyProps extends Omit<BoxProps, 'children'> {
|
| 6 |
value: number;
|
| 7 |
-
unit
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
//
|
| 13 |
-
export function Qty({ value, unit = '
|
| 14 |
-
|
| 15 |
-
const
|
| 16 |
-
const suffix = unitLabel[unit] ? ` ${unitLabel[unit]}` : '';
|
| 17 |
return (
|
| 18 |
<Box
|
| 19 |
component="span"
|
|
|
|
| 1 |
import { Box, type BoxProps } from '@mui/material';
|
| 2 |
+
import { type UnitLike, formatQty, unitLabel } from '@/lib/unit';
|
| 3 |
|
| 4 |
+
/** @deprecated short token kept for back-compat; Qty now accepts any {@link UnitLike}. */
|
| 5 |
export type QtyUnit = 'L' | 'kg' | 'u';
|
| 6 |
|
| 7 |
export interface QtyProps extends Omit<BoxProps, 'children'> {
|
| 8 |
value: number;
|
| 9 |
+
/** product unit (canonical 'litre'|'kg'|'piece'|'dozen'|'packet' or legacy 'L'|'kg'|'u') */
|
| 10 |
+
unit?: UnitLike;
|
| 11 |
+
/** hide the unit label (number only) */
|
| 12 |
+
hideLabel?: boolean;
|
| 13 |
}
|
| 14 |
|
| 15 |
+
// DESIGN_SYSTEM §8.4 — unit-aware quantity. kg up to 3dp (gram precision, trailing zeros
|
| 16 |
+
// trimmed), count units (piece/dozen/packet) as integers with an explicit label
|
| 17 |
+
// (pc/dz/pkt — never blank), litre as 0.5/1.5. No blanket toFixed(1).
|
| 18 |
+
export function Qty({ value, unit = 'litre', hideLabel = false, sx, ...rest }: QtyProps) {
|
| 19 |
+
const num = formatQty(value, unit);
|
| 20 |
+
const suffix = hideLabel ? '' : ` ${unitLabel(unit)}`;
|
|
|
|
| 21 |
return (
|
| 22 |
<Box
|
| 23 |
component="span"
|
|
@@ -61,8 +61,8 @@ export type { SpinnerProps } from './feedback/Spinner';
|
|
| 61 |
// Status & formatting
|
| 62 |
export { StatusChip } from './status/StatusChip';
|
| 63 |
export type { StatusChipProps, StatusValue } from './status/StatusChip';
|
| 64 |
-
export { Money } from './format/Money';
|
| 65 |
-
export type { MoneyProps } from './format/Money';
|
| 66 |
export { Qty } from './format/Qty';
|
| 67 |
export type { QtyProps, QtyUnit } from './format/Qty';
|
| 68 |
export { DateLabel } from './format/DateLabel';
|
|
|
|
| 61 |
// Status & formatting
|
| 62 |
export { StatusChip } from './status/StatusChip';
|
| 63 |
export type { StatusChipProps, StatusValue } from './status/StatusChip';
|
| 64 |
+
export { Money, RatePerUnit } from './format/Money';
|
| 65 |
+
export type { MoneyProps, RatePerUnitProps } from './format/Money';
|
| 66 |
export { Qty } from './format/Qty';
|
| 67 |
export type { QtyProps, QtyUnit } from './format/Qty';
|
| 68 |
export { DateLabel } from './format/DateLabel';
|
|
@@ -5,7 +5,7 @@ import Payments from '@mui/icons-material/Payments';
|
|
| 5 |
import TrendingUp from '@mui/icons-material/TrendingUp';
|
| 6 |
import ReceiptLong from '@mui/icons-material/ReceiptLong';
|
| 7 |
import People from '@mui/icons-material/People';
|
| 8 |
-
import
|
| 9 |
import Refresh from '@mui/icons-material/Refresh';
|
| 10 |
|
| 11 |
import { AppShell, NAV_TABS, type NavTab } from '@/app';
|
|
@@ -19,7 +19,6 @@ import {
|
|
| 19 |
MetricCard,
|
| 20 |
Money,
|
| 21 |
Skeleton,
|
| 22 |
-
TrendChart,
|
| 23 |
} from '@/components';
|
| 24 |
import {
|
| 25 |
useAnalyticsSummary,
|
|
@@ -29,27 +28,54 @@ import {
|
|
| 29 |
useAnalyticsOutstanding,
|
| 30 |
} from '@/api/hooks';
|
| 31 |
import { useAuthStore } from '@/auth/authStore';
|
|
|
|
| 32 |
|
| 33 |
import { useDateRange } from './useDateRange';
|
| 34 |
import { DateRangeSheet } from './DateRangeSheet';
|
| 35 |
import { RangeControl } from './RangeControl';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
const TOP_LIMIT = 10;
|
| 38 |
|
| 39 |
-
// design-dashboard.md (a) — ADMIN Dashboard
|
| 40 |
-
//
|
| 41 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
export function DashboardPage() {
|
| 43 |
const navigate = useNavigate();
|
| 44 |
const theme = useTheme();
|
| 45 |
const role = useAuthStore((s) => s.user?.role) ?? 'admin';
|
| 46 |
const tabs = NAV_TABS[role] ?? NAV_TABS.admin;
|
| 47 |
|
| 48 |
-
const { range, setRange, label, bucket } = useDateRange('30d');
|
| 49 |
const [sheetOpen, setSheetOpen] = useState(false);
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
const [topShown, setTopShown] = useState(TOP_LIMIT);
|
| 52 |
|
|
|
|
|
|
|
| 53 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 54 |
const agingRef = useRef<HTMLDivElement>(null);
|
| 55 |
|
|
@@ -59,7 +85,7 @@ export function DashboardPage() {
|
|
| 59 |
const trend = useAnalyticsSalesTrend({ ...rangeArg, bucket });
|
| 60 |
const byProduct = useAnalyticsByProduct(rangeArg);
|
| 61 |
const top = useAnalyticsTopCustomers({ ...rangeArg, limit: TOP_LIMIT });
|
| 62 |
-
// aging is as_of based (ignores from/to) — uses `to` as as_of
|
| 63 |
const aging = useAnalyticsOutstanding(range.to);
|
| 64 |
|
| 65 |
const refetchAll = () => {
|
|
@@ -75,7 +101,10 @@ export function DashboardPage() {
|
|
| 75 |
};
|
| 76 |
|
| 77 |
// --- derived view-data -----------------------------------------------------
|
| 78 |
-
const s = summary.data;
|
|
|
|
|
|
|
|
|
|
| 79 |
const growthDelta =
|
| 80 |
s?.growth_pct == null ? undefined : `${s.growth_pct > 0 ? '+' : ''}${s.growth_pct.toFixed(1)}%`;
|
| 81 |
const growthTone: 'up' | 'down' | 'flat' =
|
|
@@ -83,35 +112,58 @@ export function DashboardPage() {
|
|
| 83 |
const collectedPct =
|
| 84 |
s && s.revenue > 0 ? `${Math.round((s.collected / s.revenue) * 100)}% of rev` : undefined;
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
const trendSeries = useMemo(
|
| 87 |
() =>
|
| 88 |
(trend.data ?? []).map((p) => ({
|
| 89 |
x: p.period,
|
| 90 |
-
y:
|
| 91 |
})),
|
| 92 |
-
[trend.data,
|
| 93 |
);
|
|
|
|
|
|
|
| 94 |
|
|
|
|
| 95 |
const donutData = useMemo(
|
| 96 |
-
() =>
|
| 97 |
-
[
|
| 98 |
);
|
| 99 |
const donutTotal = donutData.reduce((acc, d) => acc + d.value, 0);
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
[aging.data],
|
| 104 |
);
|
| 105 |
-
// green -> amber -> red by age; 90+ always red (A.2).
|
| 106 |
const agingRamp = [
|
| 107 |
theme.palette.success.main,
|
| 108 |
theme.palette.warning.main,
|
| 109 |
theme.palette.accent.main,
|
| 110 |
theme.palette.error.main,
|
|
|
|
| 111 |
];
|
| 112 |
|
| 113 |
-
const topRows = (top.data ?? []).slice(0, topShown);
|
| 114 |
-
|
| 115 |
return (
|
| 116 |
<AppShell
|
| 117 |
title="Dashboard"
|
|
@@ -122,7 +174,7 @@ export function DashboardPage() {
|
|
| 122 |
scrollRef={scrollRef}
|
| 123 |
>
|
| 124 |
<Stack spacing={3}>
|
| 125 |
-
{/* METRIC CARDS — 2-col grid */}
|
| 126 |
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
|
| 127 |
<MetricCard
|
| 128 |
icon={Payments}
|
|
@@ -163,48 +215,89 @@ export function DashboardPage() {
|
|
| 163 |
/>
|
| 164 |
</Box>
|
| 165 |
|
| 166 |
-
{/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
<Card sx={{ p: 2 }}>
|
| 168 |
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
| 169 |
-
<Typography variant="h3">
|
| 170 |
-
<
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
>
|
| 177 |
-
₹
|
| 178 |
-
</AppButton>
|
| 179 |
-
<AppButton
|
| 180 |
-
variant={trendMetric === 'qty' ? 'secondary' : 'ghost'}
|
| 181 |
-
size="sm"
|
| 182 |
-
onPress={() => setTrendMetric('qty')}
|
| 183 |
-
>
|
| 184 |
-
L
|
| 185 |
-
</AppButton>
|
| 186 |
-
</Stack>
|
| 187 |
</Stack>
|
| 188 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
series={trendSeries}
|
| 190 |
-
|
| 191 |
-
|
|
|
|
| 192 |
loading={trend.isLoading}
|
| 193 |
error={trend.isError}
|
| 194 |
onRetry={() => trend.refetch()}
|
| 195 |
-
height={
|
| 196 |
/>
|
| 197 |
</Card>
|
| 198 |
|
| 199 |
-
{/* PRODUCT MIX */}
|
| 200 |
<Card sx={{ p: 2 }}>
|
| 201 |
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
| 202 |
<Typography variant="h3">Product mix</Typography>
|
| 203 |
-
<
|
|
|
|
|
|
|
| 204 |
</Stack>
|
| 205 |
<DonutChart
|
| 206 |
data={donutData}
|
| 207 |
-
centerLabel="
|
| 208 |
loading={byProduct.isLoading}
|
| 209 |
error={byProduct.isError}
|
| 210 |
onRetry={() => byProduct.refetch()}
|
|
@@ -216,7 +309,7 @@ export function DashboardPage() {
|
|
| 216 |
) : null}
|
| 217 |
</Card>
|
| 218 |
|
| 219 |
-
{/* TOP CUSTOMERS */}
|
| 220 |
<Card sx={{ p: 2 }}>
|
| 221 |
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
| 222 |
<Typography variant="h3">Top customers</Typography>
|
|
@@ -239,34 +332,46 @@ export function DashboardPage() {
|
|
| 239 |
) : topRows.length === 0 ? (
|
| 240 |
<EmptyState variant="card" icon={People} title="No customers served in this range" />
|
| 241 |
) : (
|
| 242 |
-
<Stack spacing={1}>
|
| 243 |
-
{
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
<
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
</Stack>
|
| 266 |
)}
|
| 267 |
</Card>
|
| 268 |
|
| 269 |
-
{/* OUTSTANDING AGING */}
|
| 270 |
<Card sx={{ p: 2 }} ref={agingRef}>
|
| 271 |
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
| 272 |
<Typography variant="h3">Outstanding aging</Typography>
|
|
@@ -278,20 +383,7 @@ export function DashboardPage() {
|
|
| 278 |
as of today
|
| 279 |
</Typography>
|
| 280 |
|
| 281 |
-
{aging.isLoading ? (
|
| 282 |
-
<Skeleton variant="chart" />
|
| 283 |
-
) : aging.isError ? (
|
| 284 |
-
<EmptyState
|
| 285 |
-
variant="card"
|
| 286 |
-
icon={ReceiptLong}
|
| 287 |
-
title="Couldn't load aging"
|
| 288 |
-
action={
|
| 289 |
-
<AppButton variant="ghost" size="sm" startIcon={<Refresh />} onPress={() => aging.refetch()}>
|
| 290 |
-
Retry
|
| 291 |
-
</AppButton>
|
| 292 |
-
}
|
| 293 |
-
/>
|
| 294 |
-
) : (aging.data?.total ?? 0) === 0 ? (
|
| 295 |
<Box
|
| 296 |
sx={(t) => ({
|
| 297 |
bgcolor: `${t.palette.success.main}14`,
|
|
@@ -306,9 +398,14 @@ export function DashboardPage() {
|
|
| 306 |
</Box>
|
| 307 |
) : (
|
| 308 |
<Stack spacing={1}>
|
| 309 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
<Stack spacing={0.5} component="ul" role="list" sx={{ m: 0, p: 0, listStyle: 'none' }}>
|
| 311 |
-
{(aging.data?.buckets ?? []).map((b) => (
|
| 312 |
<Stack
|
| 313 |
key={b.bucket}
|
| 314 |
component="li"
|
|
@@ -316,7 +413,17 @@ export function DashboardPage() {
|
|
| 316 |
justifyContent="space-between"
|
| 317 |
alignItems="center"
|
| 318 |
>
|
| 319 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
<Typography variant="body2" color="text.secondary">
|
| 321 |
<Money value={b.amount} component="span" /> ({b.count})
|
| 322 |
</Typography>
|
|
|
|
| 5 |
import TrendingUp from '@mui/icons-material/TrendingUp';
|
| 6 |
import ReceiptLong from '@mui/icons-material/ReceiptLong';
|
| 7 |
import People from '@mui/icons-material/People';
|
| 8 |
+
import Inventory2 from '@mui/icons-material/Inventory2';
|
| 9 |
import Refresh from '@mui/icons-material/Refresh';
|
| 10 |
|
| 11 |
import { AppShell, NAV_TABS, type NavTab } from '@/app';
|
|
|
|
| 19 |
MetricCard,
|
| 20 |
Money,
|
| 21 |
Skeleton,
|
|
|
|
| 22 |
} from '@/components';
|
| 23 |
import {
|
| 24 |
useAnalyticsSummary,
|
|
|
|
| 28 |
useAnalyticsOutstanding,
|
| 29 |
} from '@/api/hooks';
|
| 30 |
import { useAuthStore } from '@/auth/authStore';
|
| 31 |
+
import { type Unit } from '@/lib/unit';
|
| 32 |
|
| 33 |
import { useDateRange } from './useDateRange';
|
| 34 |
import { DateRangeSheet } from './DateRangeSheet';
|
| 35 |
import { RangeControl } from './RangeControl';
|
| 36 |
+
import {
|
| 37 |
+
Dropdown,
|
| 38 |
+
Segmented,
|
| 39 |
+
METRIC_OPTIONS,
|
| 40 |
+
BUCKET_OPTIONS,
|
| 41 |
+
TREND_SHAPE_OPTIONS,
|
| 42 |
+
unitFilterOptions,
|
| 43 |
+
type Metric,
|
| 44 |
+
type Bucket,
|
| 45 |
+
type TrendShape,
|
| 46 |
+
} from './controls';
|
| 47 |
+
import { FlexTrendChart, UnitBreakdownChart, AgingStackedBar } from './charts';
|
| 48 |
+
import { UnitQtyChips } from './UnitQtyChips';
|
| 49 |
+
import { unitQtyEntries, productQtyText } from './unitQty';
|
| 50 |
+
import { asSummaryUA, asByProductUA, asTopCustomersUA } from './unitTypes';
|
| 51 |
|
| 52 |
const TOP_LIMIT = 10;
|
| 53 |
|
| 54 |
+
// design-dashboard.md (a) — ADMIN Dashboard, reworked UNIT-AWARE + RICH (P-analytics-rich).
|
| 55 |
+
// Revenue (₹) is the primary, always-safe metric; quantity is NEVER summed across units —
|
| 56 |
+
// it's shown per-unit (chips + per-unit breakdown chart, or a single chosen unit on the
|
| 57 |
+
// trend). Dropdown controls: metric (Revenue/Collected/Quantity), unit filter (when
|
| 58 |
+
// Quantity), bucket (day/month), chart-type (area/line/bars). Read-only (no action bar);
|
| 59 |
+
// per-region loading/empty/error; ApexCharts animations off under reduced-motion. Gated by
|
| 60 |
+
// analytics.view at the route.
|
| 61 |
export function DashboardPage() {
|
| 62 |
const navigate = useNavigate();
|
| 63 |
const theme = useTheme();
|
| 64 |
const role = useAuthStore((s) => s.user?.role) ?? 'admin';
|
| 65 |
const tabs = NAV_TABS[role] ?? NAV_TABS.admin;
|
| 66 |
|
| 67 |
+
const { range, setRange, label, bucket: autoBucket } = useDateRange('30d');
|
| 68 |
const [sheetOpen, setSheetOpen] = useState(false);
|
| 69 |
+
|
| 70 |
+
// --- dropdown control state ------------------------------------------------
|
| 71 |
+
const [metric, setMetric] = useState<Metric>('revenue');
|
| 72 |
+
const [unitFilter, setUnitFilter] = useState<string>('all'); // 'all' | a Unit
|
| 73 |
+
const [bucketSel, setBucketSel] = useState<'auto' | Bucket>('auto');
|
| 74 |
+
const [trendShape, setTrendShape] = useState<TrendShape>('area');
|
| 75 |
const [topShown, setTopShown] = useState(TOP_LIMIT);
|
| 76 |
|
| 77 |
+
const bucket: Bucket = bucketSel === 'auto' ? autoBucket : bucketSel;
|
| 78 |
+
|
| 79 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 80 |
const agingRef = useRef<HTMLDivElement>(null);
|
| 81 |
|
|
|
|
| 85 |
const trend = useAnalyticsSalesTrend({ ...rangeArg, bucket });
|
| 86 |
const byProduct = useAnalyticsByProduct(rangeArg);
|
| 87 |
const top = useAnalyticsTopCustomers({ ...rangeArg, limit: TOP_LIMIT });
|
| 88 |
+
// aging is as_of based (ignores from/to) — uses `to` as as_of.
|
| 89 |
const aging = useAnalyticsOutstanding(range.to);
|
| 90 |
|
| 91 |
const refetchAll = () => {
|
|
|
|
| 101 |
};
|
| 102 |
|
| 103 |
// --- derived view-data -----------------------------------------------------
|
| 104 |
+
const s = asSummaryUA(summary.data);
|
| 105 |
+
const products = asByProductUA(byProduct.data);
|
| 106 |
+
const topCustomers = asTopCustomersUA(top.data);
|
| 107 |
+
|
| 108 |
const growthDelta =
|
| 109 |
s?.growth_pct == null ? undefined : `${s.growth_pct > 0 ? '+' : ''}${s.growth_pct.toFixed(1)}%`;
|
| 110 |
const growthTone: 'up' | 'down' | 'flat' =
|
|
|
|
| 112 |
const collectedPct =
|
| 113 |
s && s.revenue > 0 ? `${Math.round((s.collected / s.revenue) * 100)}% of rev` : undefined;
|
| 114 |
|
| 115 |
+
// per-unit qty (NEVER cross-unit summed) from the summary map.
|
| 116 |
+
const qtyEntries = useMemo(() => unitQtyEntries(s?.total_qty_by_unit), [s]);
|
| 117 |
+
const presentUnits = useMemo<Unit[]>(() => qtyEntries.map((e) => e.unit), [qtyEntries]);
|
| 118 |
+
const unitOpts = useMemo(() => unitFilterOptions(presentUnits), [presentUnits]);
|
| 119 |
+
// keep the unit filter valid as the present units change.
|
| 120 |
+
const activeUnit: Unit | null =
|
| 121 |
+
metric === 'quantity' && unitFilter !== 'all' && presentUnits.includes(unitFilter as Unit)
|
| 122 |
+
? (unitFilter as Unit)
|
| 123 |
+
: null;
|
| 124 |
+
|
| 125 |
+
// TREND series. Revenue/Collected use the cross-unit-safe amount. Quantity uses the
|
| 126 |
+
// trend's qty — which is meaningful ONLY when narrowed to a single unit; if no specific
|
| 127 |
+
// unit is chosen we still chart it but the axis is labelled "mixed" via the empty unit.
|
| 128 |
+
const trendIsMoney = metric !== 'quantity';
|
| 129 |
const trendSeries = useMemo(
|
| 130 |
() =>
|
| 131 |
(trend.data ?? []).map((p) => ({
|
| 132 |
x: p.period,
|
| 133 |
+
y: trendIsMoney ? p.amount : p.qty,
|
| 134 |
})),
|
| 135 |
+
[trend.data, trendIsMoney],
|
| 136 |
);
|
| 137 |
+
const trendName =
|
| 138 |
+
metric === 'revenue' ? 'Revenue' : metric === 'collected' ? 'Collected' : 'Quantity';
|
| 139 |
|
| 140 |
+
// PRODUCT MIX donut — ALWAYS by revenue (the only cross-unit-safe measure).
|
| 141 |
const donutData = useMemo(
|
| 142 |
+
() => products.map((p) => ({ label: p.product_name, value: p.amount })),
|
| 143 |
+
[products],
|
| 144 |
);
|
| 145 |
const donutTotal = donutData.reduce((acc, d) => acc + d.value, 0);
|
| 146 |
|
| 147 |
+
// TOP CUSTOMERS bar — by revenue (safe). Cards show per-unit qty when available.
|
| 148 |
+
const topRows = topCustomers.slice(0, topShown);
|
| 149 |
+
const topBarData = useMemo(
|
| 150 |
+
() => topRows.map((c) => ({ label: c.customer_name ?? 'Unknown', value: c.amount })),
|
| 151 |
+
[topRows],
|
| 152 |
+
);
|
| 153 |
+
|
| 154 |
+
// AGING — stacked bar of outstanding composition (₹).
|
| 155 |
+
const agingBuckets = useMemo(
|
| 156 |
+
() => (aging.data?.buckets ?? []).map((b) => ({ bucket: b.bucket, amount: b.amount })),
|
| 157 |
[aging.data],
|
| 158 |
);
|
|
|
|
| 159 |
const agingRamp = [
|
| 160 |
theme.palette.success.main,
|
| 161 |
theme.palette.warning.main,
|
| 162 |
theme.palette.accent.main,
|
| 163 |
theme.palette.error.main,
|
| 164 |
+
theme.palette.error.dark,
|
| 165 |
];
|
| 166 |
|
|
|
|
|
|
|
| 167 |
return (
|
| 168 |
<AppShell
|
| 169 |
title="Dashboard"
|
|
|
|
| 174 |
scrollRef={scrollRef}
|
| 175 |
>
|
| 176 |
<Stack spacing={3}>
|
| 177 |
+
{/* METRIC CARDS — 2-col grid. Revenue/Collected/Outstanding are ₹ (safe). */}
|
| 178 |
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5 }}>
|
| 179 |
<MetricCard
|
| 180 |
icon={Payments}
|
|
|
|
| 215 |
/>
|
| 216 |
</Box>
|
| 217 |
|
| 218 |
+
{/* DELIVERED QUANTITY — per-unit chips (NEVER a cross-unit "L" sum). */}
|
| 219 |
+
<Card sx={{ p: 2 }}>
|
| 220 |
+
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
| 221 |
+
<Inventory2 fontSize="small" sx={{ color: 'text.secondary' }} />
|
| 222 |
+
<Typography variant="overline" color="text.secondary">
|
| 223 |
+
Delivered quantity
|
| 224 |
+
</Typography>
|
| 225 |
+
</Stack>
|
| 226 |
+
{summary.isLoading ? (
|
| 227 |
+
<Skeleton variant="row" count={1} />
|
| 228 |
+
) : (
|
| 229 |
+
<UnitQtyChips entries={qtyEntries} emptyText="No deliveries in this range" size="medium" />
|
| 230 |
+
)}
|
| 231 |
+
<UnitBreakdownChart
|
| 232 |
+
entries={qtyEntries}
|
| 233 |
+
loading={summary.isLoading}
|
| 234 |
+
error={summary.isError}
|
| 235 |
+
onRetry={() => summary.refetch()}
|
| 236 |
+
height={Math.max(120, qtyEntries.length * 44)}
|
| 237 |
+
/>
|
| 238 |
+
</Card>
|
| 239 |
+
|
| 240 |
+
{/* SALES TREND — metric / unit / bucket / chart-type dropdowns. */}
|
| 241 |
<Card sx={{ p: 2 }}>
|
| 242 |
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
| 243 |
+
<Typography variant="h3">Trend</Typography>
|
| 244 |
+
<Segmented
|
| 245 |
+
ariaLabel="Trend chart type"
|
| 246 |
+
value={trendShape}
|
| 247 |
+
options={TREND_SHAPE_OPTIONS}
|
| 248 |
+
onChange={setTrendShape}
|
| 249 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
</Stack>
|
| 251 |
+
<Stack direction="row" flexWrap="wrap" gap={1} sx={{ mb: 1.5 }} useFlexGap>
|
| 252 |
+
<Dropdown label="Metric" value={metric} options={METRIC_OPTIONS} onChange={setMetric} minWidth={150} />
|
| 253 |
+
{metric === 'quantity' ? (
|
| 254 |
+
<Dropdown
|
| 255 |
+
label="Unit"
|
| 256 |
+
value={unitFilter}
|
| 257 |
+
options={unitOpts}
|
| 258 |
+
onChange={setUnitFilter}
|
| 259 |
+
minWidth={120}
|
| 260 |
+
/>
|
| 261 |
+
) : null}
|
| 262 |
+
<Dropdown
|
| 263 |
+
label="Bucket"
|
| 264 |
+
value={bucketSel}
|
| 265 |
+
options={[{ value: 'auto', label: `Auto (${autoBucket})` }, ...BUCKET_OPTIONS]}
|
| 266 |
+
onChange={(v) => setBucketSel(v as 'auto' | Bucket)}
|
| 267 |
+
minWidth={130}
|
| 268 |
+
/>
|
| 269 |
+
</Stack>
|
| 270 |
+
|
| 271 |
+
{metric === 'quantity' && activeUnit == null ? (
|
| 272 |
+
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
| 273 |
+
Quantity is plotted across all delivered units combined. Pick a single unit above
|
| 274 |
+
to read a clean per-unit line — magnitudes of different units are not comparable.
|
| 275 |
+
</Typography>
|
| 276 |
+
) : null}
|
| 277 |
+
|
| 278 |
+
<FlexTrendChart
|
| 279 |
series={trendSeries}
|
| 280 |
+
shape={trendShape}
|
| 281 |
+
money={trendIsMoney}
|
| 282 |
+
name={trendName}
|
| 283 |
loading={trend.isLoading}
|
| 284 |
error={trend.isError}
|
| 285 |
onRetry={() => trend.refetch()}
|
| 286 |
+
height={200}
|
| 287 |
/>
|
| 288 |
</Card>
|
| 289 |
|
| 290 |
+
{/* PRODUCT MIX — donut by revenue (cross-unit safe). */}
|
| 291 |
<Card sx={{ p: 2 }}>
|
| 292 |
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
| 293 |
<Typography variant="h3">Product mix</Typography>
|
| 294 |
+
<Typography variant="caption" color="text.secondary">
|
| 295 |
+
by revenue
|
| 296 |
+
</Typography>
|
| 297 |
</Stack>
|
| 298 |
<DonutChart
|
| 299 |
data={donutData}
|
| 300 |
+
centerLabel="Revenue"
|
| 301 |
loading={byProduct.isLoading}
|
| 302 |
error={byProduct.isError}
|
| 303 |
onRetry={() => byProduct.refetch()}
|
|
|
|
| 309 |
) : null}
|
| 310 |
</Card>
|
| 311 |
|
| 312 |
+
{/* TOP CUSTOMERS — bar by revenue + cards (per-unit qty, never "L" sum). */}
|
| 313 |
<Card sx={{ p: 2 }}>
|
| 314 |
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
| 315 |
<Typography variant="h3">Top customers</Typography>
|
|
|
|
| 332 |
) : topRows.length === 0 ? (
|
| 333 |
<EmptyState variant="card" icon={People} title="No customers served in this range" />
|
| 334 |
) : (
|
| 335 |
+
<Stack spacing={1.5}>
|
| 336 |
+
<BarChart data={topBarData} metric="amount" height={Math.max(160, topRows.length * 30)} />
|
| 337 |
+
<Stack spacing={1}>
|
| 338 |
+
{topRows.map((c, i) => {
|
| 339 |
+
const byUnit = c.by_product?.length
|
| 340 |
+
? c.by_product
|
| 341 |
+
.map((p) => productQtyText(p))
|
| 342 |
+
.filter(Boolean)
|
| 343 |
+
.join(' · ')
|
| 344 |
+
: null;
|
| 345 |
+
return (
|
| 346 |
+
<CustomerCard
|
| 347 |
+
key={c.customer_id ?? i}
|
| 348 |
+
rank={i + 1}
|
| 349 |
+
customer={{
|
| 350 |
+
id: String(c.customer_id ?? i),
|
| 351 |
+
name: c.customer_name ?? 'Unknown',
|
| 352 |
+
active: true,
|
| 353 |
+
}}
|
| 354 |
+
metrics={
|
| 355 |
+
<>
|
| 356 |
+
<Money value={c.amount} component="span" />
|
| 357 |
+
{byUnit ? ` · ${byUnit}` : ''}
|
| 358 |
+
</>
|
| 359 |
+
}
|
| 360 |
+
onTap={() => c.customer_id != null && navigate(`/customers/${c.customer_id}`)}
|
| 361 |
+
/>
|
| 362 |
+
);
|
| 363 |
+
})}
|
| 364 |
+
{topCustomers.length > topShown ? (
|
| 365 |
+
<AppButton variant="ghost" fullWidth onPress={() => setTopShown((n) => n + TOP_LIMIT)}>
|
| 366 |
+
Load more
|
| 367 |
+
</AppButton>
|
| 368 |
+
) : null}
|
| 369 |
+
</Stack>
|
| 370 |
</Stack>
|
| 371 |
)}
|
| 372 |
</Card>
|
| 373 |
|
| 374 |
+
{/* OUTSTANDING AGING — stacked composition bar (₹) + accessible legend list. */}
|
| 375 |
<Card sx={{ p: 2 }} ref={agingRef}>
|
| 376 |
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
|
| 377 |
<Typography variant="h3">Outstanding aging</Typography>
|
|
|
|
| 383 |
as of today
|
| 384 |
</Typography>
|
| 385 |
|
| 386 |
+
{(aging.data?.total ?? 0) === 0 && !aging.isLoading && !aging.isError ? (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
<Box
|
| 388 |
sx={(t) => ({
|
| 389 |
bgcolor: `${t.palette.success.main}14`,
|
|
|
|
| 398 |
</Box>
|
| 399 |
) : (
|
| 400 |
<Stack spacing={1}>
|
| 401 |
+
<AgingStackedBar
|
| 402 |
+
buckets={agingBuckets}
|
| 403 |
+
loading={aging.isLoading}
|
| 404 |
+
error={aging.isError}
|
| 405 |
+
onRetry={() => aging.refetch()}
|
| 406 |
+
/>
|
| 407 |
<Stack spacing={0.5} component="ul" role="list" sx={{ m: 0, p: 0, listStyle: 'none' }}>
|
| 408 |
+
{(aging.data?.buckets ?? []).map((b, i) => (
|
| 409 |
<Stack
|
| 410 |
key={b.bucket}
|
| 411 |
component="li"
|
|
|
|
| 413 |
justifyContent="space-between"
|
| 414 |
alignItems="center"
|
| 415 |
>
|
| 416 |
+
<Stack direction="row" alignItems="center" spacing={1}>
|
| 417 |
+
<Box
|
| 418 |
+
sx={{
|
| 419 |
+
width: 10,
|
| 420 |
+
height: 10,
|
| 421 |
+
borderRadius: '50%',
|
| 422 |
+
bgcolor: agingRamp[Math.min(i, agingRamp.length - 1)],
|
| 423 |
+
}}
|
| 424 |
+
/>
|
| 425 |
+
<Typography variant="body2">{b.bucket}</Typography>
|
| 426 |
+
</Stack>
|
| 427 |
<Typography variant="body2" color="text.secondary">
|
| 428 |
<Money value={b.amount} component="span" /> ({b.count})
|
| 429 |
</Typography>
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState, type ReactNode } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
Box,
|
|
@@ -30,10 +30,15 @@ import {
|
|
| 30 |
useAnalyticsOutstanding,
|
| 31 |
} from '@/api/hooks';
|
| 32 |
import { useAuthStore } from '@/auth/authStore';
|
|
|
|
| 33 |
|
| 34 |
import { useDateRange } from './useDateRange';
|
| 35 |
import { DateRangeSheet } from './DateRangeSheet';
|
| 36 |
import { RangeControl } from './RangeControl';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
type ReportTab = 'overview' | 'products' | 'customers' | 'aging';
|
| 39 |
const TABS = [
|
|
@@ -45,8 +50,11 @@ const TABS = [
|
|
| 45 |
|
| 46 |
const num = { fontVariantNumeric: 'tabular-nums' as const };
|
| 47 |
|
| 48 |
-
//
|
| 49 |
-
//
|
|
|
|
|
|
|
|
|
|
| 50 |
export function ReportsPage() {
|
| 51 |
const navigate = useNavigate();
|
| 52 |
const role = useAuthStore((s) => s.user?.role) ?? 'admin';
|
|
@@ -55,6 +63,7 @@ export function ReportsPage() {
|
|
| 55 |
const { range, setRange, label } = useDateRange('30d');
|
| 56 |
const [sheetOpen, setSheetOpen] = useState(false);
|
| 57 |
const [tab, setTab] = useState<ReportTab>('overview');
|
|
|
|
| 58 |
|
| 59 |
const rangeArg = { from: range.from, to: range.to };
|
| 60 |
const summary = useAnalyticsSummary(rangeArg);
|
|
@@ -66,25 +75,39 @@ export function ReportsPage() {
|
|
| 66 |
if (t.path !== '/reports') navigate(t.path);
|
| 67 |
};
|
| 68 |
|
| 69 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
const growth = s?.growth_pct == null ? '—' : `${s.growth_pct > 0 ? '+' : ''}${s.growth_pct.toFixed(1)}%`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
return (
|
| 73 |
<AppShell
|
| 74 |
title="Reports"
|
| 75 |
onMenu={() => navigate('/more')}
|
| 76 |
-
headerActions={
|
| 77 |
-
<IconAction
|
| 78 |
-
icon={Refresh}
|
| 79 |
-
label="Refresh"
|
| 80 |
-
onPress={() => {
|
| 81 |
-
summary.refetch();
|
| 82 |
-
byProduct.refetch();
|
| 83 |
-
top.refetch();
|
| 84 |
-
aging.refetch();
|
| 85 |
-
}}
|
| 86 |
-
/>
|
| 87 |
-
}
|
| 88 |
bottomNav={{ items: tabs, current: 'reports', onChange: onNav }}
|
| 89 |
subHeader={<RangeControl label={label} onOpen={() => setSheetOpen(true)} />}
|
| 90 |
>
|
|
@@ -113,20 +136,31 @@ export function ReportsPage() {
|
|
| 113 |
<TableBody>
|
| 114 |
<Row label="Revenue" value={<Money value={s?.revenue ?? 0} />} />
|
| 115 |
<Row label="Collected" value={<Money value={s?.collected ?? 0} />} />
|
| 116 |
-
<Row
|
|
|
|
|
|
|
|
|
|
| 117 |
<Row label="Active customers" value={String(s?.active_customers ?? 0)} />
|
| 118 |
<Row label="Growth vs prior" value={growth} />
|
| 119 |
</TableBody>
|
| 120 |
</Table>
|
| 121 |
)}
|
|
|
|
|
|
|
|
|
|
| 122 |
</Card>
|
| 123 |
) : null}
|
| 124 |
|
| 125 |
{tab === 'products' ? (
|
| 126 |
<Card sx={{ p: 2 }}>
|
| 127 |
-
<
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
{byProduct.isLoading ? (
|
| 131 |
<Skeleton variant="row" count={5} />
|
| 132 |
) : byProduct.isError ? (
|
|
@@ -139,30 +173,41 @@ export function ReportsPage() {
|
|
| 139 |
</AppButton>
|
| 140 |
}
|
| 141 |
/>
|
| 142 |
-
) :
|
| 143 |
<EmptyState variant="card" title="No products delivered in this range" />
|
| 144 |
) : (
|
| 145 |
<Table size="small">
|
| 146 |
<TableHead>
|
| 147 |
<TableRow>
|
| 148 |
<TableCell>Product</TableCell>
|
|
|
|
| 149 |
<TableCell align="right">Qty</TableCell>
|
| 150 |
-
<TableCell align="right">
|
| 151 |
<TableCell align="right">%</TableCell>
|
| 152 |
</TableRow>
|
| 153 |
</TableHead>
|
| 154 |
<TableBody>
|
| 155 |
-
{
|
| 156 |
<TableRow key={p.product_id}>
|
| 157 |
<TableCell>{p.product_name}</TableCell>
|
| 158 |
-
<TableCell
|
| 159 |
-
<TableCell align="right"
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
</TableRow>
|
| 162 |
))}
|
| 163 |
</TableBody>
|
| 164 |
</Table>
|
| 165 |
)}
|
|
|
|
|
|
|
|
|
|
| 166 |
</Card>
|
| 167 |
) : null}
|
| 168 |
|
|
@@ -183,7 +228,7 @@ export function ReportsPage() {
|
|
| 183 |
</AppButton>
|
| 184 |
}
|
| 185 |
/>
|
| 186 |
-
) :
|
| 187 |
<EmptyState variant="card" title="No customers served in this range" />
|
| 188 |
) : (
|
| 189 |
<Table size="small">
|
|
@@ -191,27 +236,37 @@ export function ReportsPage() {
|
|
| 191 |
<TableRow>
|
| 192 |
<TableCell>#</TableCell>
|
| 193 |
<TableCell>Customer</TableCell>
|
| 194 |
-
<TableCell align="right">
|
| 195 |
-
<TableCell
|
| 196 |
</TableRow>
|
| 197 |
</TableHead>
|
| 198 |
<TableBody>
|
| 199 |
-
{
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
</TableBody>
|
| 213 |
</Table>
|
| 214 |
)}
|
|
|
|
|
|
|
|
|
|
| 215 |
</Card>
|
| 216 |
) : null}
|
| 217 |
|
|
@@ -248,8 +303,12 @@ export function ReportsPage() {
|
|
| 248 |
{(aging.data?.buckets ?? []).map((b) => (
|
| 249 |
<TableRow key={b.bucket}>
|
| 250 |
<TableCell>{b.bucket}</TableCell>
|
| 251 |
-
<TableCell align="right">
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
</TableRow>
|
| 254 |
))}
|
| 255 |
<TableRow>
|
|
|
|
| 1 |
+
import { useMemo, useState, type ReactNode } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
Box,
|
|
|
|
| 30 |
useAnalyticsOutstanding,
|
| 31 |
} from '@/api/hooks';
|
| 32 |
import { useAuthStore } from '@/auth/authStore';
|
| 33 |
+
import { normalizeUnit, unitLabel, type Unit } from '@/lib/unit';
|
| 34 |
|
| 35 |
import { useDateRange } from './useDateRange';
|
| 36 |
import { DateRangeSheet } from './DateRangeSheet';
|
| 37 |
import { RangeControl } from './RangeControl';
|
| 38 |
+
import { Dropdown, unitFilterOptions } from './controls';
|
| 39 |
+
import { UnitQtyChips } from './UnitQtyChips';
|
| 40 |
+
import { unitQtyEntries, productQtyText, unitQtyFromProducts, unitQtyLine } from './unitQty';
|
| 41 |
+
import { asSummaryUA, asByProductUA, asTopCustomersUA } from './unitTypes';
|
| 42 |
|
| 43 |
type ReportTab = 'overview' | 'products' | 'customers' | 'aging';
|
| 44 |
const TABS = [
|
|
|
|
| 50 |
|
| 51 |
const num = { fontVariantNumeric: 'tabular-nums' as const };
|
| 52 |
|
| 53 |
+
// §9.2 Reports — same analytics data, TABULAR, reworked UNIT-CORRECT (P-analytics-rich).
|
| 54 |
+
// Quantity is shown PER UNIT (own column / chips), never a blended cross-unit number;
|
| 55 |
+
// revenue (₹) is the primary column. A unit filter narrows the Products table to one unit
|
| 56 |
+
// so the qty column is apples-to-apples. Read-only; PageTabs switch views; shared
|
| 57 |
+
// DateRangeSheet. Gated by reports.view (+ analytics.view) at route.
|
| 58 |
export function ReportsPage() {
|
| 59 |
const navigate = useNavigate();
|
| 60 |
const role = useAuthStore((s) => s.user?.role) ?? 'admin';
|
|
|
|
| 63 |
const { range, setRange, label } = useDateRange('30d');
|
| 64 |
const [sheetOpen, setSheetOpen] = useState(false);
|
| 65 |
const [tab, setTab] = useState<ReportTab>('overview');
|
| 66 |
+
const [productUnit, setProductUnit] = useState<string>('all');
|
| 67 |
|
| 68 |
const rangeArg = { from: range.from, to: range.to };
|
| 69 |
const summary = useAnalyticsSummary(rangeArg);
|
|
|
|
| 75 |
if (t.path !== '/reports') navigate(t.path);
|
| 76 |
};
|
| 77 |
|
| 78 |
+
const refetchAll = () => {
|
| 79 |
+
summary.refetch();
|
| 80 |
+
byProduct.refetch();
|
| 81 |
+
top.refetch();
|
| 82 |
+
aging.refetch();
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
// --- unit-aware view-data --------------------------------------------------
|
| 86 |
+
const s = asSummaryUA(summary.data);
|
| 87 |
+
const products = asByProductUA(byProduct.data);
|
| 88 |
+
const topCustomers = asTopCustomersUA(top.data);
|
| 89 |
+
|
| 90 |
const growth = s?.growth_pct == null ? '—' : `${s.growth_pct > 0 ? '+' : ''}${s.growth_pct.toFixed(1)}%`;
|
| 91 |
+
const qtyEntries = useMemo(() => unitQtyEntries(s?.total_qty_by_unit), [s]);
|
| 92 |
+
|
| 93 |
+
const presentUnits = useMemo<Unit[]>(
|
| 94 |
+
() => Array.from(new Set(products.map((p) => normalizeUnit(p.unit)))),
|
| 95 |
+
[products],
|
| 96 |
+
);
|
| 97 |
+
const unitOpts = useMemo(() => unitFilterOptions(presentUnits), [presentUnits]);
|
| 98 |
+
const filteredProducts = useMemo(
|
| 99 |
+
() =>
|
| 100 |
+
productUnit === 'all'
|
| 101 |
+
? products
|
| 102 |
+
: products.filter((p) => normalizeUnit(p.unit) === (productUnit as Unit)),
|
| 103 |
+
[products, productUnit],
|
| 104 |
+
);
|
| 105 |
|
| 106 |
return (
|
| 107 |
<AppShell
|
| 108 |
title="Reports"
|
| 109 |
onMenu={() => navigate('/more')}
|
| 110 |
+
headerActions={<IconAction icon={Refresh} label="Refresh" onPress={refetchAll} />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
bottomNav={{ items: tabs, current: 'reports', onChange: onNav }}
|
| 112 |
subHeader={<RangeControl label={label} onOpen={() => setSheetOpen(true)} />}
|
| 113 |
>
|
|
|
|
| 136 |
<TableBody>
|
| 137 |
<Row label="Revenue" value={<Money value={s?.revenue ?? 0} />} />
|
| 138 |
<Row label="Collected" value={<Money value={s?.collected ?? 0} />} />
|
| 139 |
+
<Row
|
| 140 |
+
label="Delivered quantity"
|
| 141 |
+
value={<UnitQtyChips entries={qtyEntries} emptyText="—" />}
|
| 142 |
+
/>
|
| 143 |
<Row label="Active customers" value={String(s?.active_customers ?? 0)} />
|
| 144 |
<Row label="Growth vs prior" value={growth} />
|
| 145 |
</TableBody>
|
| 146 |
</Table>
|
| 147 |
)}
|
| 148 |
+
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
| 149 |
+
Quantity is reported per unit — litre, kg and counts are never added together.
|
| 150 |
+
</Typography>
|
| 151 |
</Card>
|
| 152 |
) : null}
|
| 153 |
|
| 154 |
{tab === 'products' ? (
|
| 155 |
<Card sx={{ p: 2 }}>
|
| 156 |
+
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
|
| 157 |
+
<Typography variant="overline" color="text.secondary">
|
| 158 |
+
Sales by product
|
| 159 |
+
</Typography>
|
| 160 |
+
{presentUnits.length > 1 ? (
|
| 161 |
+
<Dropdown label="Unit" value={productUnit} options={unitOpts} onChange={setProductUnit} minWidth={120} />
|
| 162 |
+
) : null}
|
| 163 |
+
</Stack>
|
| 164 |
{byProduct.isLoading ? (
|
| 165 |
<Skeleton variant="row" count={5} />
|
| 166 |
) : byProduct.isError ? (
|
|
|
|
| 173 |
</AppButton>
|
| 174 |
}
|
| 175 |
/>
|
| 176 |
+
) : filteredProducts.length === 0 ? (
|
| 177 |
<EmptyState variant="card" title="No products delivered in this range" />
|
| 178 |
) : (
|
| 179 |
<Table size="small">
|
| 180 |
<TableHead>
|
| 181 |
<TableRow>
|
| 182 |
<TableCell>Product</TableCell>
|
| 183 |
+
<TableCell>Unit</TableCell>
|
| 184 |
<TableCell align="right">Qty</TableCell>
|
| 185 |
+
<TableCell align="right">Revenue</TableCell>
|
| 186 |
<TableCell align="right">%</TableCell>
|
| 187 |
</TableRow>
|
| 188 |
</TableHead>
|
| 189 |
<TableBody>
|
| 190 |
+
{filteredProducts.map((p) => (
|
| 191 |
<TableRow key={p.product_id}>
|
| 192 |
<TableCell>{p.product_name}</TableCell>
|
| 193 |
+
<TableCell>{unitLabel(normalizeUnit(p.unit))}</TableCell>
|
| 194 |
+
<TableCell align="right" sx={num}>
|
| 195 |
+
{productQtyText(p)}
|
| 196 |
+
</TableCell>
|
| 197 |
+
<TableCell align="right">
|
| 198 |
+
<Money value={p.amount} />
|
| 199 |
+
</TableCell>
|
| 200 |
+
<TableCell align="right" sx={num}>
|
| 201 |
+
{p.pct_of_revenue.toFixed(0)}%
|
| 202 |
+
</TableCell>
|
| 203 |
</TableRow>
|
| 204 |
))}
|
| 205 |
</TableBody>
|
| 206 |
</Table>
|
| 207 |
)}
|
| 208 |
+
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
| 209 |
+
Each row's qty is in its own unit; the % column is share of total revenue (safe to sum).
|
| 210 |
+
</Typography>
|
| 211 |
</Card>
|
| 212 |
) : null}
|
| 213 |
|
|
|
|
| 228 |
</AppButton>
|
| 229 |
}
|
| 230 |
/>
|
| 231 |
+
) : topCustomers.length === 0 ? (
|
| 232 |
<EmptyState variant="card" title="No customers served in this range" />
|
| 233 |
) : (
|
| 234 |
<Table size="small">
|
|
|
|
| 236 |
<TableRow>
|
| 237 |
<TableCell>#</TableCell>
|
| 238 |
<TableCell>Customer</TableCell>
|
| 239 |
+
<TableCell align="right">Revenue</TableCell>
|
| 240 |
+
<TableCell>Quantity (per unit)</TableCell>
|
| 241 |
</TableRow>
|
| 242 |
</TableHead>
|
| 243 |
<TableBody>
|
| 244 |
+
{topCustomers.map((c, i) => {
|
| 245 |
+
const perUnit = c.by_product?.length
|
| 246 |
+
? unitQtyLine(unitQtyFromProducts(c.by_product))
|
| 247 |
+
: '—';
|
| 248 |
+
return (
|
| 249 |
+
<TableRow
|
| 250 |
+
key={c.customer_id ?? i}
|
| 251 |
+
hover
|
| 252 |
+
sx={{ cursor: c.customer_id != null ? 'pointer' : 'default' }}
|
| 253 |
+
onClick={() => c.customer_id != null && navigate(`/customers/${c.customer_id}`)}
|
| 254 |
+
>
|
| 255 |
+
<TableCell sx={num}>{i + 1}</TableCell>
|
| 256 |
+
<TableCell>{c.customer_name ?? 'Unknown'}</TableCell>
|
| 257 |
+
<TableCell align="right">
|
| 258 |
+
<Money value={c.amount} />
|
| 259 |
+
</TableCell>
|
| 260 |
+
<TableCell sx={num}>{perUnit}</TableCell>
|
| 261 |
+
</TableRow>
|
| 262 |
+
);
|
| 263 |
+
})}
|
| 264 |
</TableBody>
|
| 265 |
</Table>
|
| 266 |
)}
|
| 267 |
+
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
| 268 |
+
Per-customer quantity is broken out by unit, not blended into one number.
|
| 269 |
+
</Typography>
|
| 270 |
</Card>
|
| 271 |
) : null}
|
| 272 |
|
|
|
|
| 303 |
{(aging.data?.buckets ?? []).map((b) => (
|
| 304 |
<TableRow key={b.bucket}>
|
| 305 |
<TableCell>{b.bucket}</TableCell>
|
| 306 |
+
<TableCell align="right">
|
| 307 |
+
<Money value={b.amount} />
|
| 308 |
+
</TableCell>
|
| 309 |
+
<TableCell align="right" sx={num}>
|
| 310 |
+
{b.count}
|
| 311 |
+
</TableCell>
|
| 312 |
</TableRow>
|
| 313 |
))}
|
| 314 |
<TableRow>
|
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Chip, Stack, Typography } from '@mui/material';
|
| 2 |
+
import type { UnitQty } from './unitQty';
|
| 3 |
+
|
| 4 |
+
export interface UnitQtyChipsProps {
|
| 5 |
+
entries: UnitQty[];
|
| 6 |
+
/** shown when there are no quantities (e.g. revenue-only range) */
|
| 7 |
+
emptyText?: string;
|
| 8 |
+
size?: 'small' | 'medium';
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
// SCENARIOS_UNITS §D1 — render delivered quantity as PER-UNIT chips ("200 L · 5 kg ·
|
| 12 |
+
// 120 pc") instead of a meaningless cross-unit "L" sum. Each unit is its own chip so the
|
| 13 |
+
// numbers are never visually added together. Wraps on a narrow phone.
|
| 14 |
+
export function UnitQtyChips({ entries, emptyText = 'No deliveries', size = 'small' }: UnitQtyChipsProps) {
|
| 15 |
+
if (entries.length === 0) {
|
| 16 |
+
return (
|
| 17 |
+
<Typography variant="body2" color="text.secondary">
|
| 18 |
+
{emptyText}
|
| 19 |
+
</Typography>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
return (
|
| 23 |
+
<Stack direction="row" flexWrap="wrap" gap={0.75} useFlexGap>
|
| 24 |
+
{entries.map((e) => (
|
| 25 |
+
<Chip
|
| 26 |
+
key={e.unit}
|
| 27 |
+
size={size}
|
| 28 |
+
label={e.text}
|
| 29 |
+
sx={(t) => ({
|
| 30 |
+
fontVariantNumeric: 'tabular-nums',
|
| 31 |
+
fontWeight: 600,
|
| 32 |
+
borderRadius: `${t.custom.radius.sm}px`,
|
| 33 |
+
bgcolor: `${t.palette.primary.main}14`,
|
| 34 |
+
color: 'primary.main',
|
| 35 |
+
})}
|
| 36 |
+
/>
|
| 37 |
+
))}
|
| 38 |
+
</Stack>
|
| 39 |
+
);
|
| 40 |
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useTheme } from '@mui/material';
|
| 2 |
+
import ReactApexChart from 'react-apexcharts';
|
| 3 |
+
import type { ApexOptions } from 'apexcharts';
|
| 4 |
+
import { ChartFrame } from '@/components/charts/ChartFrame';
|
| 5 |
+
import { useReducedMotion } from '@/hooks/useReducedMotion';
|
| 6 |
+
import type { TrendShape } from './controls';
|
| 7 |
+
import type { UnitQty } from './unitQty';
|
| 8 |
+
import { unitLabel } from '@/lib/unit';
|
| 9 |
+
|
| 10 |
+
// Analytics-local ApexCharts wrappers that the shared library doesn't cover:
|
| 11 |
+
// - FlexTrendChart : trend with a SHAPE toggle (area | line | bars) + ₹/qty formatter
|
| 12 |
+
// - UnitBreakdownChart: per-unit delivered-qty bars (one bar per unit, distinct colors) —
|
| 13 |
+
// the only safe way to chart quantity without summing across units.
|
| 14 |
+
// Both set animations off under reduced motion and degrade to scroll on a narrow phone.
|
| 15 |
+
|
| 16 |
+
const moneyFmt = (v: number) => `₹${Math.round(v)}`;
|
| 17 |
+
const plainFmt = (v: number) => String(Math.round(v));
|
| 18 |
+
|
| 19 |
+
export interface FlexTrendChartProps {
|
| 20 |
+
series: { x: string; y: number }[];
|
| 21 |
+
shape: TrendShape;
|
| 22 |
+
/** value formatting: '₹' for revenue/collected, '' for quantity */
|
| 23 |
+
money: boolean;
|
| 24 |
+
/** series name in the tooltip */
|
| 25 |
+
name: string;
|
| 26 |
+
loading?: boolean;
|
| 27 |
+
error?: boolean;
|
| 28 |
+
onRetry?: () => void;
|
| 29 |
+
height?: number;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function FlexTrendChart({
|
| 33 |
+
series,
|
| 34 |
+
shape,
|
| 35 |
+
money,
|
| 36 |
+
name,
|
| 37 |
+
loading = false,
|
| 38 |
+
error = false,
|
| 39 |
+
onRetry,
|
| 40 |
+
height = 200,
|
| 41 |
+
}: FlexTrendChartProps) {
|
| 42 |
+
const theme = useTheme();
|
| 43 |
+
const reduced = useReducedMotion();
|
| 44 |
+
const empty = !loading && !error && series.length === 0;
|
| 45 |
+
const apexType = shape === 'bar' ? 'bar' : shape === 'line' ? 'line' : 'area';
|
| 46 |
+
const fmt = money ? moneyFmt : plainFmt;
|
| 47 |
+
|
| 48 |
+
const options: ApexOptions = {
|
| 49 |
+
chart: {
|
| 50 |
+
type: apexType,
|
| 51 |
+
toolbar: { show: false },
|
| 52 |
+
zoom: { enabled: false },
|
| 53 |
+
animations: { enabled: !reduced, speed: 600 },
|
| 54 |
+
fontFamily: theme.typography.fontFamily,
|
| 55 |
+
parentHeightOffset: 0,
|
| 56 |
+
},
|
| 57 |
+
colors: [money ? theme.palette.primary.main : theme.palette.secondary.main],
|
| 58 |
+
dataLabels: { enabled: false },
|
| 59 |
+
stroke: { curve: 'smooth', width: shape === 'bar' ? 0 : 2.5 },
|
| 60 |
+
fill:
|
| 61 |
+
shape === 'area'
|
| 62 |
+
? { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.35, opacityTo: 0.05, stops: [0, 100] } }
|
| 63 |
+
: { opacity: shape === 'bar' ? 0.9 : 1 },
|
| 64 |
+
plotOptions: { bar: { borderRadius: 4, columnWidth: '60%' } },
|
| 65 |
+
grid: { borderColor: theme.custom.border, strokeDashArray: 4, padding: { left: 4, right: 4 } },
|
| 66 |
+
xaxis: {
|
| 67 |
+
type: 'category',
|
| 68 |
+
categories: series.map((p) => p.x),
|
| 69 |
+
labels: { style: { colors: theme.palette.text.secondary, fontSize: '11px' }, rotate: 0, hideOverlappingLabels: true },
|
| 70 |
+
axisBorder: { show: false },
|
| 71 |
+
axisTicks: { show: false },
|
| 72 |
+
tickAmount: 6,
|
| 73 |
+
},
|
| 74 |
+
yaxis: {
|
| 75 |
+
labels: { style: { colors: theme.palette.text.secondary, fontSize: '11px' }, formatter: (v) => fmt(Number(v)) },
|
| 76 |
+
},
|
| 77 |
+
tooltip: { theme: 'light', y: { formatter: (v) => fmt(Number(v)) } },
|
| 78 |
+
legend: { show: false },
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<ChartFrame loading={loading} error={error} empty={empty} onRetry={onRetry} height={height}>
|
| 83 |
+
<ReactApexChart
|
| 84 |
+
type={apexType}
|
| 85 |
+
height={height}
|
| 86 |
+
options={options}
|
| 87 |
+
series={[{ name, data: series.map((p) => p.y) }]}
|
| 88 |
+
/>
|
| 89 |
+
</ChartFrame>
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export interface UnitBreakdownChartProps {
|
| 94 |
+
entries: UnitQty[];
|
| 95 |
+
loading?: boolean;
|
| 96 |
+
error?: boolean;
|
| 97 |
+
onRetry?: () => void;
|
| 98 |
+
height?: number;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// Per-unit delivered quantity as a horizontal bar chart — one distinctly-coloured bar per
|
| 102 |
+
// unit (L / kg / pc / dz / pkt). NEVER a single summed total; each axis tick is its own unit
|
| 103 |
+
// so the magnitudes are not comparable-as-added. Tooltip shows the unit's own label.
|
| 104 |
+
export function UnitBreakdownChart({ entries, loading = false, error = false, onRetry, height = 180 }: UnitBreakdownChartProps) {
|
| 105 |
+
const theme = useTheme();
|
| 106 |
+
const reduced = useReducedMotion();
|
| 107 |
+
const empty = !loading && !error && entries.length === 0;
|
| 108 |
+
const ramp = [
|
| 109 |
+
theme.palette.primary.main,
|
| 110 |
+
theme.palette.secondary.main,
|
| 111 |
+
theme.palette.accent.main,
|
| 112 |
+
theme.palette.info.main,
|
| 113 |
+
theme.palette.warning.main,
|
| 114 |
+
];
|
| 115 |
+
|
| 116 |
+
const options: ApexOptions = {
|
| 117 |
+
chart: {
|
| 118 |
+
type: 'bar',
|
| 119 |
+
toolbar: { show: false },
|
| 120 |
+
animations: { enabled: !reduced, speed: 600 },
|
| 121 |
+
fontFamily: theme.typography.fontFamily,
|
| 122 |
+
parentHeightOffset: 0,
|
| 123 |
+
},
|
| 124 |
+
colors: ramp,
|
| 125 |
+
plotOptions: { bar: { horizontal: true, borderRadius: 6, barHeight: '55%', distributed: true } },
|
| 126 |
+
dataLabels: {
|
| 127 |
+
enabled: true,
|
| 128 |
+
formatter: (_v, opts) => entries[opts.dataPointIndex]?.text ?? '',
|
| 129 |
+
style: { colors: [theme.palette.text.primary], fontSize: '11px' },
|
| 130 |
+
offsetX: 4,
|
| 131 |
+
},
|
| 132 |
+
grid: { borderColor: theme.custom.border, strokeDashArray: 4 },
|
| 133 |
+
xaxis: {
|
| 134 |
+
categories: entries.map((e) => unitLabel(e.unit)),
|
| 135 |
+
labels: { show: false },
|
| 136 |
+
axisBorder: { show: false },
|
| 137 |
+
axisTicks: { show: false },
|
| 138 |
+
},
|
| 139 |
+
yaxis: { labels: { style: { colors: theme.palette.text.primary, fontSize: '12px' } } },
|
| 140 |
+
legend: { show: false },
|
| 141 |
+
tooltip: {
|
| 142 |
+
theme: 'light',
|
| 143 |
+
y: { formatter: (v, opts) => entries[opts?.dataPointIndex ?? 0]?.text ?? String(v) },
|
| 144 |
+
},
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
return (
|
| 148 |
+
<ChartFrame loading={loading} error={error} empty={empty} onRetry={onRetry} height={height} emptyTitle="No deliveries in this range">
|
| 149 |
+
<ReactApexChart
|
| 150 |
+
type="bar"
|
| 151 |
+
height={height}
|
| 152 |
+
options={options}
|
| 153 |
+
series={[{ name: 'Quantity', data: entries.map((e) => e.qty) }]}
|
| 154 |
+
/>
|
| 155 |
+
</ChartFrame>
|
| 156 |
+
);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
export interface AgingBucketDatum {
|
| 160 |
+
bucket: string;
|
| 161 |
+
amount: number;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
export interface AgingStackedBarProps {
|
| 165 |
+
buckets: AgingBucketDatum[];
|
| 166 |
+
loading?: boolean;
|
| 167 |
+
error?: boolean;
|
| 168 |
+
onRetry?: () => void;
|
| 169 |
+
height?: number;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// Outstanding aging as a single STACKED horizontal bar: each aging bucket (Current / 1-30 /
|
| 173 |
+
// 31-60 / 61-90 / 90+) is a coloured segment of the one total-outstanding bar, so the owner
|
| 174 |
+
// sees the COMPOSITION of what's owed at a glance (green = fresh, red = 90+). Stacked because
|
| 175 |
+
// the segments share one summable money axis (₹ is cross-unit safe). Legend list is rendered
|
| 176 |
+
// by the page (accessible, §10).
|
| 177 |
+
export function AgingStackedBar({ buckets, loading = false, error = false, onRetry, height = 96 }: AgingStackedBarProps) {
|
| 178 |
+
const theme = useTheme();
|
| 179 |
+
const reduced = useReducedMotion();
|
| 180 |
+
const nonZero = buckets.filter((b) => b.amount > 0);
|
| 181 |
+
const empty = !loading && !error && nonZero.length === 0;
|
| 182 |
+
|
| 183 |
+
// green -> amber -> accent -> red by age; 90+ always red.
|
| 184 |
+
const ramp = [
|
| 185 |
+
theme.palette.success.main,
|
| 186 |
+
theme.palette.warning.main,
|
| 187 |
+
theme.palette.accent.main,
|
| 188 |
+
theme.palette.error.main,
|
| 189 |
+
theme.palette.error.dark,
|
| 190 |
+
];
|
| 191 |
+
|
| 192 |
+
const options: ApexOptions = {
|
| 193 |
+
chart: {
|
| 194 |
+
type: 'bar',
|
| 195 |
+
stacked: true,
|
| 196 |
+
stackType: '100%',
|
| 197 |
+
toolbar: { show: false },
|
| 198 |
+
animations: { enabled: !reduced, speed: 600 },
|
| 199 |
+
fontFamily: theme.typography.fontFamily,
|
| 200 |
+
parentHeightOffset: 0,
|
| 201 |
+
},
|
| 202 |
+
colors: nonZero.map((_, i) => ramp[Math.min(i, ramp.length - 1)]),
|
| 203 |
+
plotOptions: { bar: { horizontal: true, borderRadius: 4, barHeight: '46%' } },
|
| 204 |
+
dataLabels: { enabled: false },
|
| 205 |
+
grid: { show: false, padding: { top: -10, bottom: -10 } },
|
| 206 |
+
xaxis: { categories: ['Outstanding'], labels: { show: false }, axisBorder: { show: false }, axisTicks: { show: false } },
|
| 207 |
+
yaxis: { labels: { show: false } },
|
| 208 |
+
legend: { show: false },
|
| 209 |
+
tooltip: { theme: 'light', y: { formatter: (v) => moneyFmt(Number(v)) } },
|
| 210 |
+
};
|
| 211 |
+
|
| 212 |
+
return (
|
| 213 |
+
<ChartFrame loading={loading} error={error} empty={empty} onRetry={onRetry} height={height} emptyTitle="Nothing outstanding">
|
| 214 |
+
<ReactApexChart
|
| 215 |
+
type="bar"
|
| 216 |
+
height={height}
|
| 217 |
+
options={options}
|
| 218 |
+
series={nonZero.map((b) => ({ name: b.bucket, data: [b.amount] }))}
|
| 219 |
+
/>
|
| 220 |
+
</ChartFrame>
|
| 221 |
+
);
|
| 222 |
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MenuItem, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
| 2 |
+
import type { Unit } from '@/lib/unit';
|
| 3 |
+
import { unitLabel } from '@/lib/unit';
|
| 4 |
+
|
| 5 |
+
// Lightweight, NON-react-hook-form dropdown + segmented controls for the analytics
|
| 6 |
+
// dashboard/reports. The shared `SelectField` is RHF-bound (needs a `control`); these
|
| 7 |
+
// page-state controls are plain value/onChange selects so the dashboard can drive
|
| 8 |
+
// metric / unit / bucket / chart-type without a form. They reuse MUI primitives and the
|
| 9 |
+
// theme tokens (radius/colors) so they match the design system.
|
| 10 |
+
|
| 11 |
+
export interface DropdownOption<V extends string> {
|
| 12 |
+
value: V;
|
| 13 |
+
label: string;
|
| 14 |
+
disabled?: boolean;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface DropdownProps<V extends string> {
|
| 18 |
+
label: string;
|
| 19 |
+
value: V;
|
| 20 |
+
options: DropdownOption<V>[];
|
| 21 |
+
onChange: (value: V) => void;
|
| 22 |
+
/** compact width for inline placement next to a chart title */
|
| 23 |
+
minWidth?: number;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// A compact labelled dropdown (metric / unit / bucket / date-preset).
|
| 27 |
+
export function Dropdown<V extends string>({
|
| 28 |
+
label,
|
| 29 |
+
value,
|
| 30 |
+
options,
|
| 31 |
+
onChange,
|
| 32 |
+
minWidth = 130,
|
| 33 |
+
}: DropdownProps<V>) {
|
| 34 |
+
return (
|
| 35 |
+
<TextField
|
| 36 |
+
select
|
| 37 |
+
size="small"
|
| 38 |
+
label={label}
|
| 39 |
+
value={value}
|
| 40 |
+
onChange={(e) => onChange(e.target.value as V)}
|
| 41 |
+
sx={{ minWidth }}
|
| 42 |
+
SelectProps={{ MenuProps: { disableScrollLock: true } }}
|
| 43 |
+
>
|
| 44 |
+
{options.map((o) => (
|
| 45 |
+
<MenuItem key={o.value} value={o.value} disabled={o.disabled}>
|
| 46 |
+
{o.label}
|
| 47 |
+
</MenuItem>
|
| 48 |
+
))}
|
| 49 |
+
</TextField>
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export interface SegmentedProps<V extends string> {
|
| 54 |
+
value: V;
|
| 55 |
+
options: DropdownOption<V>[];
|
| 56 |
+
onChange: (value: V) => void;
|
| 57 |
+
ariaLabel: string;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// A small segmented control (chart-type toggle: line / area / bar). 44px tap targets.
|
| 61 |
+
export function Segmented<V extends string>({ value, options, onChange, ariaLabel }: SegmentedProps<V>) {
|
| 62 |
+
return (
|
| 63 |
+
<ToggleButtonGroup
|
| 64 |
+
exclusive
|
| 65 |
+
size="small"
|
| 66 |
+
value={value}
|
| 67 |
+
aria-label={ariaLabel}
|
| 68 |
+
onChange={(_, v) => v && onChange(v as V)}
|
| 69 |
+
sx={{
|
| 70 |
+
'& .MuiToggleButton-root': {
|
| 71 |
+
textTransform: 'none',
|
| 72 |
+
fontWeight: 600,
|
| 73 |
+
minHeight: 36,
|
| 74 |
+
px: 1.25,
|
| 75 |
+
border: (t) => `1px solid ${t.custom.border}`,
|
| 76 |
+
},
|
| 77 |
+
}}
|
| 78 |
+
>
|
| 79 |
+
{options.map((o) => (
|
| 80 |
+
<ToggleButton key={o.value} value={o.value} disabled={o.disabled} aria-label={o.label}>
|
| 81 |
+
{o.label}
|
| 82 |
+
</ToggleButton>
|
| 83 |
+
))}
|
| 84 |
+
</ToggleButtonGroup>
|
| 85 |
+
);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// ------------------------------- typed control vocab -------------------------------
|
| 89 |
+
|
| 90 |
+
/** Headline metric. Revenue is ALWAYS safe (cross-unit summable); Quantity is per-unit. */
|
| 91 |
+
export type Metric = 'revenue' | 'collected' | 'quantity';
|
| 92 |
+
export const METRIC_OPTIONS: DropdownOption<Metric>[] = [
|
| 93 |
+
{ value: 'revenue', label: 'Revenue (₹)' },
|
| 94 |
+
{ value: 'collected', label: 'Collected (₹)' },
|
| 95 |
+
{ value: 'quantity', label: 'Quantity' },
|
| 96 |
+
];
|
| 97 |
+
|
| 98 |
+
/** Trend bucket. */
|
| 99 |
+
export type Bucket = 'day' | 'month';
|
| 100 |
+
export const BUCKET_OPTIONS: DropdownOption<Bucket>[] = [
|
| 101 |
+
{ value: 'day', label: 'Daily' },
|
| 102 |
+
{ value: 'month', label: 'Monthly' },
|
| 103 |
+
];
|
| 104 |
+
|
| 105 |
+
/** Trend chart shape. */
|
| 106 |
+
export type TrendShape = 'area' | 'line' | 'bar';
|
| 107 |
+
export const TREND_SHAPE_OPTIONS: DropdownOption<TrendShape>[] = [
|
| 108 |
+
{ value: 'area', label: 'Area' },
|
| 109 |
+
{ value: 'line', label: 'Line' },
|
| 110 |
+
{ value: 'bar', label: 'Bars' },
|
| 111 |
+
];
|
| 112 |
+
|
| 113 |
+
/** Build unit-filter options from the units actually present (revenue charts ignore this). */
|
| 114 |
+
export function unitFilterOptions(units: Unit[]): DropdownOption<string>[] {
|
| 115 |
+
return [
|
| 116 |
+
{ value: 'all', label: 'All units' },
|
| 117 |
+
...units.map((u) => ({ value: u, label: unitLabel(u) })),
|
| 118 |
+
];
|
| 119 |
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Per-unit quantity helpers for the analytics area. The cardinal rule (SCENARIOS_UNITS
|
| 2 |
+
// §D1): quantity is summable ONLY within a unit. We never collapse litre + kg + piece
|
| 3 |
+
// into one scalar — revenue (₹) is the only safe cross-unit metric. These helpers turn
|
| 4 |
+
// the backend's per-unit map (and per-product unit rows) into render-ready chips/series.
|
| 5 |
+
|
| 6 |
+
import { formatQty, normalizeUnit, unitLabel, type Unit } from '@/lib/unit';
|
| 7 |
+
import type { ProductSalesUA, QtyByUnit } from './unitTypes';
|
| 8 |
+
|
| 9 |
+
/** One per-unit quantity entry, normalized + label-ready. */
|
| 10 |
+
export interface UnitQty {
|
| 11 |
+
unit: Unit;
|
| 12 |
+
qty: number;
|
| 13 |
+
/** e.g. "200 L", "5 kg", "120 pc" */
|
| 14 |
+
text: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Stable display order so chips don't reshuffle between renders.
|
| 18 |
+
const UNIT_ORDER: Unit[] = ['litre', 'kg', 'piece', 'dozen', 'packet'];
|
| 19 |
+
|
| 20 |
+
/** Convert a `total_qty_by_unit` map to ordered, formatted entries (drops zero/empty). */
|
| 21 |
+
export function unitQtyEntries(map?: QtyByUnit | null): UnitQty[] {
|
| 22 |
+
if (!map) return [];
|
| 23 |
+
const acc = new Map<Unit, number>();
|
| 24 |
+
for (const [raw, qty] of Object.entries(map)) {
|
| 25 |
+
if (!qty) continue;
|
| 26 |
+
const u = normalizeUnit(raw);
|
| 27 |
+
acc.set(u, (acc.get(u) ?? 0) + qty);
|
| 28 |
+
}
|
| 29 |
+
return UNIT_ORDER.filter((u) => acc.has(u)).map((u) => {
|
| 30 |
+
const qty = acc.get(u) as number;
|
| 31 |
+
return { unit: u, qty, text: `${formatQty(qty, u)} ${unitLabel(u)}` };
|
| 32 |
+
});
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/** Roll up a list of per-product rows into a per-unit qty map (e.g. a customer's by_product). */
|
| 36 |
+
export function unitQtyFromProducts(rows: ProductSalesUA[]): UnitQty[] {
|
| 37 |
+
const map: QtyByUnit = {};
|
| 38 |
+
for (const r of rows) {
|
| 39 |
+
const u = normalizeUnit(r.unit);
|
| 40 |
+
map[u] = (map[u] ?? 0) + (r.qty ?? 0);
|
| 41 |
+
}
|
| 42 |
+
return unitQtyEntries(map);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** Compact one-line summary: "200 L · 5 kg · 120 pc" (em-dot separated). "—" when empty. */
|
| 46 |
+
export function unitQtyLine(entries: UnitQty[]): string {
|
| 47 |
+
if (entries.length === 0) return '—';
|
| 48 |
+
return entries.map((e) => e.text).join(' · ');
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/** Format a single product row's qty with its own unit (never a blended number). */
|
| 52 |
+
export function productQtyText(row: ProductSalesUA): string {
|
| 53 |
+
const u = normalizeUnit(row.unit);
|
| 54 |
+
return `${formatQty(row.qty ?? 0, u)} ${unitLabel(u)}`;
|
| 55 |
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Analytics-local view types.
|
| 2 |
+
//
|
| 3 |
+
// The deployed backend now returns unit-aware fields the shared `api/types.ts`
|
| 4 |
+
// does not yet declare (it predates the unit rollout):
|
| 5 |
+
// - SalesSummary.total_qty_by_unit : map unit -> qty (per-unit, never summed)
|
| 6 |
+
// - ProductSales.unit : the product's unit (per-row qty unit)
|
| 7 |
+
// - CustomerSales.by_product[].unit
|
| 8 |
+
// We extend the shared shapes LOCALLY (additive, optional) and narrow the query
|
| 9 |
+
// data at the call sites with `asUnitAware*`. This keeps the change surgical to
|
| 10 |
+
// features/analytics/* and never touches the shared contract or other pages.
|
| 11 |
+
|
| 12 |
+
import type {
|
| 13 |
+
SalesSummary,
|
| 14 |
+
ProductSales,
|
| 15 |
+
CustomerSales,
|
| 16 |
+
} from '@/api/types';
|
| 17 |
+
import type { Unit } from '@/lib/unit';
|
| 18 |
+
|
| 19 |
+
/** A `total_qty_by_unit` map: canonical unit name -> delivered qty. */
|
| 20 |
+
export type QtyByUnit = Partial<Record<Unit, number>> & Record<string, number>;
|
| 21 |
+
|
| 22 |
+
/** SalesSummary as the unit-aware backend actually returns it. */
|
| 23 |
+
export interface SalesSummaryUA extends SalesSummary {
|
| 24 |
+
/** delivered qty summed PER unit; render as chips, never cross-unit summed. */
|
| 25 |
+
total_qty_by_unit?: QtyByUnit | null;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/** ProductSales row carrying the product's unit for correct per-row qty display. */
|
| 29 |
+
export interface ProductSalesUA extends ProductSales {
|
| 30 |
+
unit?: string;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/** CustomerSales whose by_product rows carry units. */
|
| 34 |
+
export interface CustomerSalesUA extends CustomerSales {
|
| 35 |
+
by_product?: ProductSalesUA[];
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export const asSummaryUA = (s?: SalesSummary): SalesSummaryUA | undefined =>
|
| 39 |
+
s as SalesSummaryUA | undefined;
|
| 40 |
+
|
| 41 |
+
export const asByProductUA = (rows?: ProductSales[]): ProductSalesUA[] =>
|
| 42 |
+
(rows as ProductSalesUA[] | undefined) ?? [];
|
| 43 |
+
|
| 44 |
+
export const asTopCustomersUA = (rows?: CustomerSales[]): CustomerSalesUA[] =>
|
| 45 |
+
(rows as CustomerSalesUA[] | undefined) ?? [];
|
|
@@ -47,15 +47,16 @@ import {
|
|
| 47 |
useSettings,
|
| 48 |
usePayments,
|
| 49 |
} from '@/api';
|
| 50 |
-
import type { BillAdjustment
|
|
|
|
| 51 |
import { hasPermission } from '@/auth/permissions';
|
| 52 |
import { useAuthStore } from '@/auth/authStore';
|
| 53 |
import { useBillingBottomNav } from './hooks/useBillingShell';
|
|
|
|
| 54 |
import { AdjustmentSheet } from './components/AdjustmentSheet';
|
| 55 |
import { RecordPaymentSheet } from './components/RecordPaymentSheet';
|
| 56 |
import { StatusChangeSheet } from './components/StatusChangeSheet';
|
| 57 |
|
| 58 |
-
const UNIT_MAP: Record<ProductUnit, 'L' | 'kg' | 'u'> = { litre: 'L', kg: 'kg', piece: 'u' };
|
| 59 |
|
| 60 |
// design-billing.md §3 — Bill detail. Lines / tax / carry-forward / totals / QR /
|
| 61 |
// WhatsApp (primary) + Record payment (secondary) / adjustments / status change.
|
|
@@ -88,21 +89,24 @@ export function BillDetailPage() {
|
|
| 88 |
const delAdjMut = useDeleteBillAdjustment();
|
| 89 |
|
| 90 |
const productName = useMemo(() => {
|
| 91 |
-
const m = new Map<number, { name: string; unit:
|
| 92 |
-
(productsQ.data?.items ?? []).forEach((p) => m.set(p.id, { name: p.name, unit:
|
| 93 |
return m;
|
| 94 |
}, [productsQ.data]);
|
| 95 |
|
| 96 |
const mm = bill ? String(bill.period_month).padStart(2, '0') : '';
|
| 97 |
const invoiceNo = bill ? `INV-${bill.period_year}-${mm}-${bill.id}` : '';
|
| 98 |
|
|
|
|
|
|
|
|
|
|
| 99 |
const domainLines: DomainBillLine[] = useMemo(
|
| 100 |
() =>
|
| 101 |
-
(bill?.lines ?? []).map((l) => {
|
| 102 |
const meta = l.product_id != null ? productName.get(l.product_id) : undefined;
|
| 103 |
return {
|
| 104 |
-
productName: meta?.name
|
| 105 |
-
unit: meta?.unit
|
| 106 |
qty: l.qty,
|
| 107 |
rate: l.rate,
|
| 108 |
amount: l.amount,
|
|
|
|
| 47 |
useSettings,
|
| 48 |
usePayments,
|
| 49 |
} from '@/api';
|
| 50 |
+
import type { BillAdjustment } from '@/api/types';
|
| 51 |
+
import { type Unit, normalizeUnit } from '@/lib/unit';
|
| 52 |
import { hasPermission } from '@/auth/permissions';
|
| 53 |
import { useAuthStore } from '@/auth/authStore';
|
| 54 |
import { useBillingBottomNav } from './hooks/useBillingShell';
|
| 55 |
+
import { type BillLineWithSnapshot, billLineUnit, billLineName } from './lib/billLineUnit';
|
| 56 |
import { AdjustmentSheet } from './components/AdjustmentSheet';
|
| 57 |
import { RecordPaymentSheet } from './components/RecordPaymentSheet';
|
| 58 |
import { StatusChangeSheet } from './components/StatusChangeSheet';
|
| 59 |
|
|
|
|
| 60 |
|
| 61 |
// design-billing.md §3 — Bill detail. Lines / tax / carry-forward / totals / QR /
|
| 62 |
// WhatsApp (primary) + Record payment (secondary) / adjustments / status change.
|
|
|
|
| 89 |
const delAdjMut = useDeleteBillAdjustment();
|
| 90 |
|
| 91 |
const productName = useMemo(() => {
|
| 92 |
+
const m = new Map<number, { name: string; unit: Unit }>();
|
| 93 |
+
(productsQ.data?.items ?? []).forEach((p) => m.set(p.id, { name: p.name, unit: normalizeUnit(p.unit) }));
|
| 94 |
return m;
|
| 95 |
}, [productsQ.data]);
|
| 96 |
|
| 97 |
const mm = bill ? String(bill.period_month).padStart(2, '0') : '';
|
| 98 |
const invoiceNo = bill ? `INV-${bill.period_year}-${mm}-${bill.id}` : '';
|
| 99 |
|
| 100 |
+
// Unit/name come from the SNAPSHOT the backend now stamps on each line, so historical
|
| 101 |
+
// bills stay correct after a product is edited (litre->kg) or deleted. The live catalog
|
| 102 |
+
// is only a fallback for older lines that predate the snapshot (SCENARIOS_UNITS §D4/C3).
|
| 103 |
const domainLines: DomainBillLine[] = useMemo(
|
| 104 |
() =>
|
| 105 |
+
((bill?.lines ?? []) as BillLineWithSnapshot[]).map((l) => {
|
| 106 |
const meta = l.product_id != null ? productName.get(l.product_id) : undefined;
|
| 107 |
return {
|
| 108 |
+
productName: billLineName(l, meta?.name),
|
| 109 |
+
unit: billLineUnit(l, meta?.unit),
|
| 110 |
qty: l.qty,
|
| 111 |
rate: l.rate,
|
| 112 |
amount: l.amount,
|
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Box, Card, Stack, Typography } from '@mui/material';
|
| 2 |
+
import ReceiptLong from '@mui/icons-material/ReceiptLong';
|
| 3 |
+
import { Money, StatusChip } from '@/components';
|
| 4 |
+
import type { BillStatus } from '@/api/types';
|
| 5 |
+
|
| 6 |
+
export interface BillListCardProps {
|
| 7 |
+
customer: string;
|
| 8 |
+
invoiceNo: string;
|
| 9 |
+
amountDue: number;
|
| 10 |
+
remaining?: number;
|
| 11 |
+
status: BillStatus;
|
| 12 |
+
onTap?: () => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// Bills-list item for the billing feature. Visually identical to the shared `BillCard`
|
| 16 |
+
// (DESIGN_SYSTEM §8.4) — avatar, customer, invoice#, ₹ amount, status/remaining — but it
|
| 17 |
+
// deliberately DOES NOT render `Bill.total_qty`. That field is a cross-unit SUM
|
| 18 |
+
// (litre + kg + piece collapsed into one scalar), so labelling it "L" is meaningless the
|
| 19 |
+
// moment the catalog has any non-litre product (SCENARIOS_UNITS §D1/D4). The only safely
|
| 20 |
+
// summable cross-unit measure is money, which the card already shows prominently, so the
|
| 21 |
+
// secondary line here is just the invoice number. We keep this inside the feature instead
|
| 22 |
+
// of editing the shared BillCard (still used by the customer-ledger view).
|
| 23 |
+
export function BillListCard({ customer, invoiceNo, amountDue, remaining, status, onTap }: BillListCardProps) {
|
| 24 |
+
return (
|
| 25 |
+
<Card sx={{ p: 2, cursor: onTap ? 'pointer' : 'default' }} onClick={onTap} role={onTap ? 'button' : undefined}>
|
| 26 |
+
<Stack direction="row" spacing={1.5} alignItems="center">
|
| 27 |
+
<Box
|
| 28 |
+
sx={(t) => ({
|
| 29 |
+
width: 40,
|
| 30 |
+
height: 40,
|
| 31 |
+
borderRadius: `${t.custom.radius.md}px`,
|
| 32 |
+
bgcolor: 'background.default',
|
| 33 |
+
color: 'primary.main',
|
| 34 |
+
display: 'grid',
|
| 35 |
+
placeItems: 'center',
|
| 36 |
+
})}
|
| 37 |
+
>
|
| 38 |
+
<ReceiptLong fontSize="small" />
|
| 39 |
+
</Box>
|
| 40 |
+
<Box sx={{ flex: 1, minWidth: 0 }}>
|
| 41 |
+
<Typography variant="subtitle1" noWrap>
|
| 42 |
+
{customer}
|
| 43 |
+
</Typography>
|
| 44 |
+
<Typography variant="body2" color="text.secondary" noWrap>
|
| 45 |
+
{invoiceNo}
|
| 46 |
+
</Typography>
|
| 47 |
+
</Box>
|
| 48 |
+
<Stack alignItems="flex-end" spacing={0.5}>
|
| 49 |
+
<Typography variant="subtitle1">
|
| 50 |
+
<Money value={amountDue} />
|
| 51 |
+
</Typography>
|
| 52 |
+
{status === 'partial' && remaining !== undefined ? (
|
| 53 |
+
<Typography variant="caption" color="warning.main">
|
| 54 |
+
<Money value={remaining} component="span" /> left
|
| 55 |
+
</Typography>
|
| 56 |
+
) : (
|
| 57 |
+
<StatusChip status={status} />
|
| 58 |
+
)}
|
| 59 |
+
</Stack>
|
| 60 |
+
</Stack>
|
| 61 |
+
</Card>
|
| 62 |
+
);
|
| 63 |
+
}
|
|
@@ -3,7 +3,8 @@ import { Chip, Stack, Typography } from '@mui/material';
|
|
| 3 |
import ReceiptLong from '@mui/icons-material/ReceiptLong';
|
| 4 |
import { billsApi, useCustomers } from '@/api';
|
| 5 |
import type { Bill, Paged, PageParams } from '@/api/types';
|
| 6 |
-
import {
|
|
|
|
| 7 |
|
| 8 |
export interface BillsListTabProps {
|
| 9 |
year: number;
|
|
@@ -105,10 +106,12 @@ export function BillsListTab({ year, month, searchOpen, onOpenBill, onGoGenerate
|
|
| 105 |
mode="infinite"
|
| 106 |
pullToRefresh
|
| 107 |
renderItem={(b) => (
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
| 109 |
customer={nameById.get(b.customer_id) ?? `Customer #${b.customer_id}`}
|
| 110 |
invoiceNo={invoiceNo(b)}
|
| 111 |
-
qty={b.total_qty}
|
| 112 |
amountDue={b.amount_due}
|
| 113 |
remaining={b.remaining}
|
| 114 |
status={b.status}
|
|
|
|
| 3 |
import ReceiptLong from '@mui/icons-material/ReceiptLong';
|
| 4 |
import { billsApi, useCustomers } from '@/api';
|
| 5 |
import type { Bill, Paged, PageParams } from '@/api/types';
|
| 6 |
+
import { PagedList, SearchBar, AppButton } from '@/components';
|
| 7 |
+
import { BillListCard } from './BillListCard';
|
| 8 |
|
| 9 |
export interface BillsListTabProps {
|
| 10 |
year: number;
|
|
|
|
| 106 |
mode="infinite"
|
| 107 |
pullToRefresh
|
| 108 |
renderItem={(b) => (
|
| 109 |
+
// BillListCard (feature-local) intentionally omits b.total_qty: it is a cross-unit
|
| 110 |
+
// SUM and cannot be labelled with one unit (SCENARIOS_UNITS §D1/D4). Money is the
|
| 111 |
+
// only safely summable cross-unit measure, and the card shows ₹ amount_due.
|
| 112 |
+
<BillListCard
|
| 113 |
customer={nameById.get(b.customer_id) ?? `Customer #${b.customer_id}`}
|
| 114 |
invoiceNo={invoiceNo(b)}
|
|
|
|
| 115 |
amountDue={b.amount_due}
|
| 116 |
remaining={b.remaining}
|
| 117 |
status={b.status}
|
|
@@ -15,7 +15,6 @@ import {
|
|
| 15 |
IconAction,
|
| 16 |
MetricCard,
|
| 17 |
Money,
|
| 18 |
-
Qty,
|
| 19 |
Skeleton,
|
| 20 |
ErrorState,
|
| 21 |
Spinner,
|
|
@@ -94,10 +93,12 @@ export function GenerateTab({ ctl }: { ctl: GenerateTabController }) {
|
|
| 94 |
value={ctl.preview.customerCount}
|
| 95 |
note="active"
|
| 96 |
/>
|
|
|
|
|
|
|
| 97 |
<MetricCard
|
| 98 |
icon={WaterDrop}
|
| 99 |
label="Total qty"
|
| 100 |
-
value={
|
| 101 |
note="this period (est.)"
|
| 102 |
/>
|
| 103 |
<MetricCard
|
|
|
|
| 15 |
IconAction,
|
| 16 |
MetricCard,
|
| 17 |
Money,
|
|
|
|
| 18 |
Skeleton,
|
| 19 |
ErrorState,
|
| 20 |
Spinner,
|
|
|
|
| 93 |
value={ctl.preview.customerCount}
|
| 94 |
note="active"
|
| 95 |
/>
|
| 96 |
+
{/* Per-unit breakdown ("200 L · 5 kg · 120 pc") — quantity is summable only within
|
| 97 |
+
a unit, so we never show a single cross-unit total here (SCENARIOS_UNITS §D1). */}
|
| 98 |
<MetricCard
|
| 99 |
icon={WaterDrop}
|
| 100 |
label="Total qty"
|
| 101 |
+
value={ctl.preview.qtyByUnitLabel}
|
| 102 |
note="this period (est.)"
|
| 103 |
/>
|
| 104 |
<MetricCard
|
|
@@ -1,7 +1,14 @@
|
|
| 1 |
import { useMemo } from 'react';
|
| 2 |
import { useCustomers, useBills, useAnalyticsByProduct } from '@/api';
|
|
|
|
|
|
|
| 3 |
import type { GenerateTarget } from './useGenerateBills';
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
// design-billing.md §1a: there is no preview endpoint. Compute the preview from data
|
| 6 |
// the app already fetches: active customers (GET /customers), the month's existing
|
| 7 |
// bills (GET /bills?year=&month=) for the "already billed → will be re-generated"
|
|
@@ -14,7 +21,14 @@ export interface GeneratePreview {
|
|
| 14 |
customerCount: number;
|
| 15 |
/** customers that already have a bill this period (re-generated/recomputed) */
|
| 16 |
alreadyBilled: number;
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
estAmount: number;
|
| 19 |
/** total bills the CTA will (re)generate = customerCount */
|
| 20 |
toGenerate: number;
|
|
@@ -46,11 +60,20 @@ export function useGeneratePreview(year: number, month: number): GeneratePreview
|
|
| 46 |
return targets.filter((t) => ids.has(t.customerId)).length;
|
| 47 |
}, [billsQ.data, targets]);
|
| 48 |
|
| 49 |
-
const {
|
| 50 |
-
const rows = byProductQ.data ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
return {
|
| 52 |
-
|
| 53 |
-
|
|
|
|
| 54 |
};
|
| 55 |
}, [byProductQ.data]);
|
| 56 |
|
|
@@ -60,7 +83,8 @@ export function useGeneratePreview(year: number, month: number): GeneratePreview
|
|
| 60 |
targets,
|
| 61 |
customerCount: targets.length,
|
| 62 |
alreadyBilled,
|
| 63 |
-
|
|
|
|
| 64 |
estAmount,
|
| 65 |
toGenerate: targets.length,
|
| 66 |
refetch: () => {
|
|
|
|
| 1 |
import { useMemo } from 'react';
|
| 2 |
import { useCustomers, useBills, useAnalyticsByProduct } from '@/api';
|
| 3 |
+
import type { ProductSales } from '@/api/types';
|
| 4 |
+
import { type Unit, type UnitLike, normalizeUnit, UNIT, formatQty, unitLabel } from '@/lib/unit';
|
| 5 |
import type { GenerateTarget } from './useGenerateBills';
|
| 6 |
|
| 7 |
+
// analytics by_product now carries `unit` per row (backend deployed). The shared
|
| 8 |
+
// ProductSales type is owned by the integrator and not yet widened, so we read the
|
| 9 |
+
// snapshot unit off the row via a narrow structural type (same pattern as bill lines).
|
| 10 |
+
type ProductSalesWithUnit = ProductSales & { unit?: UnitLike };
|
| 11 |
+
|
| 12 |
// design-billing.md §1a: there is no preview endpoint. Compute the preview from data
|
| 13 |
// the app already fetches: active customers (GET /customers), the month's existing
|
| 14 |
// bills (GET /bills?year=&month=) for the "already billed → will be re-generated"
|
|
|
|
| 21 |
customerCount: number;
|
| 22 |
/** customers that already have a bill this period (re-generated/recomputed) */
|
| 23 |
alreadyBilled: number;
|
| 24 |
+
/**
|
| 25 |
+
* Per-unit quantity breakdown for the period, e.g. { litre: 200, kg: 5, piece: 120 }.
|
| 26 |
+
* Quantity is summable ONLY within a unit, so the preview never collapses these into a
|
| 27 |
+
* single scalar (SCENARIOS_UNITS §D1).
|
| 28 |
+
*/
|
| 29 |
+
qtyByUnit: Record<Unit, number>;
|
| 30 |
+
/** Pre-formatted "200 L · 5 kg · 120 pc" (empty units omitted), or '—' when none. */
|
| 31 |
+
qtyByUnitLabel: string;
|
| 32 |
estAmount: number;
|
| 33 |
/** total bills the CTA will (re)generate = customerCount */
|
| 34 |
toGenerate: number;
|
|
|
|
| 60 |
return targets.filter((t) => ids.has(t.customerId)).length;
|
| 61 |
}, [billsQ.data, targets]);
|
| 62 |
|
| 63 |
+
const { qtyByUnit, qtyByUnitLabel, estAmount } = useMemo(() => {
|
| 64 |
+
const rows = (byProductQ.data ?? []) as ProductSalesWithUnit[];
|
| 65 |
+
const byUnit: Record<Unit, number> = { litre: 0, kg: 0, piece: 0, dozen: 0, packet: 0 };
|
| 66 |
+
let amount = 0;
|
| 67 |
+
for (const r of rows) {
|
| 68 |
+
byUnit[normalizeUnit(r.unit)] += r.qty ?? 0;
|
| 69 |
+
amount += r.amount ?? 0;
|
| 70 |
+
}
|
| 71 |
+
// Render in canonical unit order, skipping units with no quantity, e.g. "200 L · 5 kg".
|
| 72 |
+
const parts = UNIT.filter((u) => byUnit[u] > 0).map((u) => `${formatQty(byUnit[u], u)} ${unitLabel(u)}`);
|
| 73 |
return {
|
| 74 |
+
qtyByUnit: byUnit,
|
| 75 |
+
qtyByUnitLabel: parts.length ? parts.join(' · ') : '—',
|
| 76 |
+
estAmount: amount,
|
| 77 |
};
|
| 78 |
}, [byProductQ.data]);
|
| 79 |
|
|
|
|
| 83 |
targets,
|
| 84 |
customerCount: targets.length,
|
| 85 |
alreadyBilled,
|
| 86 |
+
qtyByUnit,
|
| 87 |
+
qtyByUnitLabel,
|
| 88 |
estAmount,
|
| 89 |
toGenerate: targets.length,
|
| 90 |
refetch: () => {
|
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Billing-local adapter for the SNAPSHOTTED unit (and name) the backend now stamps on
|
| 2 |
+
// every bill line at generation time. We must read the unit from the bill line itself —
|
| 3 |
+
// NOT infer it from the live catalog — so historical bills stay correct after a product
|
| 4 |
+
// is edited (litre -> kg) or deleted. (SCENARIOS_UNITS §D4 / C3.)
|
| 5 |
+
//
|
| 6 |
+
// The shared `BillLine` API type (src/api/types.ts) is owned by the integrator and does
|
| 7 |
+
// not yet surface the new snapshot fields; that file is off-limits to this feature. To
|
| 8 |
+
// stay surgical we read the fields off the raw row via a narrow structural type and a
|
| 9 |
+
// safe accessor, instead of widening the shared interface. When the shared type is later
|
| 10 |
+
// extended these helpers keep working unchanged.
|
| 11 |
+
|
| 12 |
+
import type { BillLine } from '@/api/types';
|
| 13 |
+
import { type Unit, type UnitLike, normalizeUnit } from '@/lib/unit';
|
| 14 |
+
|
| 15 |
+
/** The snapshot fields the backend now returns on each bill line, layered onto the
|
| 16 |
+
* current shared `BillLine` type without editing it. */
|
| 17 |
+
export type BillLineWithSnapshot = BillLine & {
|
| 18 |
+
/** unit snapshotted at bill time (litre|kg|piece|dozen|packet). */
|
| 19 |
+
unit?: UnitLike;
|
| 20 |
+
/** product name snapshotted at bill time (present once the backend stamps it). */
|
| 21 |
+
product_name?: string | null;
|
| 22 |
+
name?: string | null;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* The unit to DISPLAY for a bill line. Prefers the snapshot stamped on the line; only when
|
| 27 |
+
* the line has no snapshot (older bills generated before the backend change) does it fall
|
| 28 |
+
* back to a caller-supplied live-catalog unit, and finally to 'litre'. This is what keeps
|
| 29 |
+
* a March ghee line reading "0.5 kg" after the product is deleted, instead of "0.5 L".
|
| 30 |
+
*/
|
| 31 |
+
export function billLineUnit(line: BillLineWithSnapshot, fallback?: UnitLike): Unit {
|
| 32 |
+
if (line.unit != null) return normalizeUnit(line.unit);
|
| 33 |
+
if (fallback != null) return normalizeUnit(fallback);
|
| 34 |
+
return 'litre';
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/**
|
| 38 |
+
* The product NAME to DISPLAY for a bill line. Prefers the snapshot stamped on the line so
|
| 39 |
+
* a deleted product still reads by its real name; otherwise uses the live-catalog name the
|
| 40 |
+
* caller resolved, then a generic "Item".
|
| 41 |
+
*/
|
| 42 |
+
export function billLineName(line: BillLineWithSnapshot, fallback?: string): string {
|
| 43 |
+
return line.product_name ?? line.name ?? fallback ?? 'Item';
|
| 44 |
+
}
|
|
@@ -13,12 +13,18 @@ import {
|
|
| 13 |
} from '@/components';
|
| 14 |
import { useAddPrice } from '@/api/hooks/usePrices';
|
| 15 |
import { toApiError } from '@/api/ApiError';
|
| 16 |
-
import type { PriceInput } from '@/api/types';
|
|
|
|
| 17 |
|
| 18 |
export interface AddPriceSheetProps {
|
| 19 |
open: boolean;
|
| 20 |
onClose: () => void;
|
| 21 |
productId: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
/** optional customer options for a customer-specific override price */
|
| 23 |
customerOptions: SelectOption[];
|
| 24 |
}
|
|
@@ -34,7 +40,13 @@ const todayIso = () => new Date().toISOString().slice(0, 10);
|
|
| 34 |
|
| 35 |
// DESIGN_SYSTEM §9.2 — add an effective-dated price for a product (rate + effective_from),
|
| 36 |
// optionally a customer-specific override. Gated by prices.manage at the trigger.
|
| 37 |
-
export function AddPriceSheet({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const toast = useToast();
|
| 39 |
const add = useAddPrice();
|
| 40 |
const { control, handleSubmit, reset, setError } = useForm<FormValues>({
|
|
@@ -101,7 +113,18 @@ export function AddPriceSheet({ open, onClose, productId, customerOptions }: Add
|
|
| 101 |
}
|
| 102 |
>
|
| 103 |
<Stack spacing={2}>
|
| 104 |
-
<NumberField
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
<DateField name="effective_from" control={control} label="Effective from" required />
|
| 106 |
{hasCustomers ? (
|
| 107 |
<SelectField
|
|
|
|
| 13 |
} from '@/components';
|
| 14 |
import { useAddPrice } from '@/api/hooks/usePrices';
|
| 15 |
import { toApiError } from '@/api/ApiError';
|
| 16 |
+
import type { PriceInput, ProductUnit } from '@/api/types';
|
| 17 |
+
import { unitLabel } from '@/lib/unit';
|
| 18 |
|
| 19 |
export interface AddPriceSheetProps {
|
| 20 |
open: boolean;
|
| 21 |
onClose: () => void;
|
| 22 |
productId: number;
|
| 23 |
+
/**
|
| 24 |
+
* The product's unit — drives the Rate field's per-unit suffix ('per kg', 'per pc',
|
| 25 |
+
* 'per L') so the operator knows ₹600 means ₹600/kg, not ₹600/pack. Defaults to litre.
|
| 26 |
+
*/
|
| 27 |
+
unit?: ProductUnit;
|
| 28 |
/** optional customer options for a customer-specific override price */
|
| 29 |
customerOptions: SelectOption[];
|
| 30 |
}
|
|
|
|
| 40 |
|
| 41 |
// DESIGN_SYSTEM §9.2 — add an effective-dated price for a product (rate + effective_from),
|
| 42 |
// optionally a customer-specific override. Gated by prices.manage at the trigger.
|
| 43 |
+
export function AddPriceSheet({
|
| 44 |
+
open,
|
| 45 |
+
onClose,
|
| 46 |
+
productId,
|
| 47 |
+
unit,
|
| 48 |
+
customerOptions,
|
| 49 |
+
}: AddPriceSheetProps) {
|
| 50 |
const toast = useToast();
|
| 51 |
const add = useAddPrice();
|
| 52 |
const { control, handleSubmit, reset, setError } = useForm<FormValues>({
|
|
|
|
| 113 |
}
|
| 114 |
>
|
| 115 |
<Stack spacing={2}>
|
| 116 |
+
<NumberField
|
| 117 |
+
name="rate"
|
| 118 |
+
control={control}
|
| 119 |
+
label="Rate"
|
| 120 |
+
money
|
| 121 |
+
// Per-unit suffix derived from the product's unit ('₹ … per kg'); a rate is always
|
| 122 |
+
// decimal (₹7.33/pc is valid) so we do NOT force the integer keypad here.
|
| 123 |
+
unit={`per ${unitLabel(unit)}`}
|
| 124 |
+
min={0}
|
| 125 |
+
step={0.5}
|
| 126 |
+
required
|
| 127 |
+
/>
|
| 128 |
<DateField name="effective_from" control={control} label="Effective from" required />
|
| 129 |
{hasCustomers ? (
|
| 130 |
<SelectField
|
|
@@ -1,26 +1,29 @@
|
|
| 1 |
import { Card, Stack, Box, Typography } from '@mui/material';
|
| 2 |
import Person from '@mui/icons-material/Person';
|
| 3 |
import DeleteOutline from '@mui/icons-material/DeleteOutline';
|
| 4 |
-
import {
|
| 5 |
-
import type { Price } from '@/api/types';
|
| 6 |
|
| 7 |
export interface PriceRowProps {
|
| 8 |
price: Price;
|
|
|
|
|
|
|
| 9 |
/** display name for a customer-specific override row */
|
| 10 |
customerName?: string;
|
| 11 |
/** delete affordance (only rendered when prices.manage) */
|
| 12 |
onDelete?: () => void;
|
| 13 |
}
|
| 14 |
|
| 15 |
-
// A single effective-dated price row in a product's price history.
|
| 16 |
-
|
|
|
|
| 17 |
const isOverride = price.customer_id != null;
|
| 18 |
return (
|
| 19 |
<Card sx={{ p: 2 }}>
|
| 20 |
<Stack direction="row" spacing={1.5} alignItems="center">
|
| 21 |
<Box sx={{ flex: 1, minWidth: 0 }}>
|
| 22 |
<Typography variant="subtitle1">
|
| 23 |
-
<
|
| 24 |
</Typography>
|
| 25 |
<Typography variant="body2" color="text.secondary">
|
| 26 |
From <DateLabel value={price.effective_from} />
|
|
|
|
| 1 |
import { Card, Stack, Box, Typography } from '@mui/material';
|
| 2 |
import Person from '@mui/icons-material/Person';
|
| 3 |
import DeleteOutline from '@mui/icons-material/DeleteOutline';
|
| 4 |
+
import { RatePerUnit, DateLabel, IconAction } from '@/components';
|
| 5 |
+
import type { Price, ProductUnit } from '@/api/types';
|
| 6 |
|
| 7 |
export interface PriceRowProps {
|
| 8 |
price: Price;
|
| 9 |
+
/** the owning product's unit — drives the per-unit suffix (₹600/kg, ₹7/pc, ₹56/L) */
|
| 10 |
+
unit?: ProductUnit;
|
| 11 |
/** display name for a customer-specific override row */
|
| 12 |
customerName?: string;
|
| 13 |
/** delete affordance (only rendered when prices.manage) */
|
| 14 |
onDelete?: () => void;
|
| 15 |
}
|
| 16 |
|
| 17 |
+
// A single effective-dated price row in a product's price history. The rate is always
|
| 18 |
+
// expressed per the product's unit (never a bare number) so ₹600/kg ≠ ₹600/L is obvious.
|
| 19 |
+
export function PriceRow({ price, unit, customerName, onDelete }: PriceRowProps) {
|
| 20 |
const isOverride = price.customer_id != null;
|
| 21 |
return (
|
| 22 |
<Card sx={{ p: 2 }}>
|
| 23 |
<Stack direction="row" spacing={1.5} alignItems="center">
|
| 24 |
<Box sx={{ flex: 1, minWidth: 0 }}>
|
| 25 |
<Typography variant="subtitle1">
|
| 26 |
+
<RatePerUnit value={price.rate} unit={unit} />
|
| 27 |
</Typography>
|
| 28 |
<Typography variant="body2" color="text.secondary">
|
| 29 |
From <DateLabel value={price.effective_from} />
|
|
@@ -4,14 +4,17 @@ import Sell from '@mui/icons-material/Sell';
|
|
| 4 |
import {
|
| 5 |
DateField,
|
| 6 |
SelectField,
|
| 7 |
-
|
| 8 |
Spinner,
|
| 9 |
type SelectOption,
|
| 10 |
} from '@/components';
|
| 11 |
import { useResolveRate } from '@/api/hooks/usePrices';
|
|
|
|
| 12 |
|
| 13 |
export interface ResolveRatePreviewProps {
|
| 14 |
productId: number;
|
|
|
|
|
|
|
| 15 |
customerOptions: SelectOption[];
|
| 16 |
}
|
| 17 |
|
|
@@ -24,7 +27,7 @@ const todayIso = () => new Date().toISOString().slice(0, 10);
|
|
| 24 |
|
| 25 |
// DESIGN_SYSTEM §9.2 — "resolve rate" preview: pick a date (+ optional customer) and the
|
| 26 |
// server resolves the effective rate (GET /prices/resolve). Read-only, auth-only.
|
| 27 |
-
export function ResolveRatePreview({ productId, customerOptions }: ResolveRatePreviewProps) {
|
| 28 |
const { control } = useForm<FormValues>({
|
| 29 |
defaultValues: { date: todayIso(), customer_id: '' },
|
| 30 |
});
|
|
@@ -68,7 +71,7 @@ export function ResolveRatePreview({ productId, customerOptions }: ResolveRatePr
|
|
| 68 |
</Typography>
|
| 69 |
) : data?.found ? (
|
| 70 |
<Typography variant="subtitle1">
|
| 71 |
-
<
|
| 72 |
</Typography>
|
| 73 |
) : (
|
| 74 |
<Typography variant="body2" color="text.secondary">
|
|
|
|
| 4 |
import {
|
| 5 |
DateField,
|
| 6 |
SelectField,
|
| 7 |
+
RatePerUnit,
|
| 8 |
Spinner,
|
| 9 |
type SelectOption,
|
| 10 |
} from '@/components';
|
| 11 |
import { useResolveRate } from '@/api/hooks/usePrices';
|
| 12 |
+
import type { ProductUnit } from '@/api/types';
|
| 13 |
|
| 14 |
export interface ResolveRatePreviewProps {
|
| 15 |
productId: number;
|
| 16 |
+
/** product unit — the resolved rate is shown per unit (₹600/kg, ₹7/pc, ₹56/L) */
|
| 17 |
+
unit?: ProductUnit;
|
| 18 |
customerOptions: SelectOption[];
|
| 19 |
}
|
| 20 |
|
|
|
|
| 27 |
|
| 28 |
// DESIGN_SYSTEM §9.2 — "resolve rate" preview: pick a date (+ optional customer) and the
|
| 29 |
// server resolves the effective rate (GET /prices/resolve). Read-only, auth-only.
|
| 30 |
+
export function ResolveRatePreview({ productId, unit, customerOptions }: ResolveRatePreviewProps) {
|
| 31 |
const { control } = useForm<FormValues>({
|
| 32 |
defaultValues: { date: todayIso(), customer_id: '' },
|
| 33 |
});
|
|
|
|
| 71 |
</Typography>
|
| 72 |
) : data?.found ? (
|
| 73 |
<Typography variant="subtitle1">
|
| 74 |
+
<RatePerUnit value={data.rate} unit={unit} />
|
| 75 |
</Typography>
|
| 76 |
) : (
|
| 77 |
<Typography variant="body2" color="text.secondary">
|
|
@@ -13,7 +13,6 @@ import {
|
|
| 13 |
ErrorState,
|
| 14 |
ConfirmDialog,
|
| 15 |
PermissionGate,
|
| 16 |
-
Qty,
|
| 17 |
useToast,
|
| 18 |
type AppButtonState,
|
| 19 |
type SelectOption,
|
|
@@ -24,7 +23,7 @@ import { usePrices, useDeletePrice } from '@/api/hooks/usePrices';
|
|
| 24 |
import { useCustomers } from '@/api/hooks/useCustomers';
|
| 25 |
import { toApiError } from '@/api/ApiError';
|
| 26 |
import type { Price } from '@/api/types';
|
| 27 |
-
import {
|
| 28 |
import { useCatalogNav } from '../useCatalogNav';
|
| 29 |
import { PriceRow } from '../components/PriceRow';
|
| 30 |
import { AddPriceSheet } from '../components/AddPriceSheet';
|
|
@@ -136,7 +135,7 @@ export function ProductPricesPage() {
|
|
| 136 |
{product.name}
|
| 137 |
</Typography>
|
| 138 |
<Typography variant="body2" color="text.secondary">
|
| 139 |
-
|
| 140 |
{product.taxable ? ` · Tax ${product.tax_rate}%` : ' · Tax-free'}
|
| 141 |
{product.category ? ` · ${product.category}` : ''}
|
| 142 |
</Typography>
|
|
@@ -175,6 +174,7 @@ export function ProductPricesPage() {
|
|
| 175 |
<PriceRow
|
| 176 |
key={price.id}
|
| 177 |
price={price}
|
|
|
|
| 178 |
customerName={
|
| 179 |
price.customer_id != null ? customerName.get(price.customer_id) : undefined
|
| 180 |
}
|
|
@@ -187,7 +187,11 @@ export function ProductPricesPage() {
|
|
| 187 |
{/* Resolve rate preview */}
|
| 188 |
{product ? (
|
| 189 |
<Box sx={{ mt: 1 }}>
|
| 190 |
-
<ResolveRatePreview
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
</Box>
|
| 192 |
) : null}
|
| 193 |
|
|
@@ -219,6 +223,7 @@ export function ProductPricesPage() {
|
|
| 219 |
open={addOpen}
|
| 220 |
onClose={() => setAddOpen(false)}
|
| 221 |
productId={productId}
|
|
|
|
| 222 |
customerOptions={customerOptions}
|
| 223 |
/>
|
| 224 |
</PermissionGate>
|
|
|
|
| 13 |
ErrorState,
|
| 14 |
ConfirmDialog,
|
| 15 |
PermissionGate,
|
|
|
|
| 16 |
useToast,
|
| 17 |
type AppButtonState,
|
| 18 |
type SelectOption,
|
|
|
|
| 23 |
import { useCustomers } from '@/api/hooks/useCustomers';
|
| 24 |
import { toApiError } from '@/api/ApiError';
|
| 25 |
import type { Price } from '@/api/types';
|
| 26 |
+
import { unitName } from '../unit';
|
| 27 |
import { useCatalogNav } from '../useCatalogNav';
|
| 28 |
import { PriceRow } from '../components/PriceRow';
|
| 29 |
import { AddPriceSheet } from '../components/AddPriceSheet';
|
|
|
|
| 135 |
{product.name}
|
| 136 |
</Typography>
|
| 137 |
<Typography variant="body2" color="text.secondary">
|
| 138 |
+
Sold by {unitName(product.unit)}
|
| 139 |
{product.taxable ? ` · Tax ${product.tax_rate}%` : ' · Tax-free'}
|
| 140 |
{product.category ? ` · ${product.category}` : ''}
|
| 141 |
</Typography>
|
|
|
|
| 174 |
<PriceRow
|
| 175 |
key={price.id}
|
| 176 |
price={price}
|
| 177 |
+
unit={product?.unit}
|
| 178 |
customerName={
|
| 179 |
price.customer_id != null ? customerName.get(price.customer_id) : undefined
|
| 180 |
}
|
|
|
|
| 187 |
{/* Resolve rate preview */}
|
| 188 |
{product ? (
|
| 189 |
<Box sx={{ mt: 1 }}>
|
| 190 |
+
<ResolveRatePreview
|
| 191 |
+
productId={productId}
|
| 192 |
+
unit={product.unit}
|
| 193 |
+
customerOptions={customerOptions}
|
| 194 |
+
/>
|
| 195 |
</Box>
|
| 196 |
) : null}
|
| 197 |
|
|
|
|
| 223 |
open={addOpen}
|
| 224 |
onClose={() => setAddOpen(false)}
|
| 225 |
productId={productId}
|
| 226 |
+
unit={product?.unit}
|
| 227 |
customerOptions={customerOptions}
|
| 228 |
/>
|
| 229 |
</PermissionGate>
|
|
@@ -1,21 +1,31 @@
|
|
| 1 |
import type { ProductUnit } from '@/api/types';
|
|
|
|
| 2 |
|
| 3 |
-
// Map the backend ProductUnit (
|
| 4 |
-
// presentational library (ProductCard / Qty / NumberField adornment)
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
return 'kg';
|
| 9 |
-
case 'piece':
|
| 10 |
-
return 'u';
|
| 11 |
-
case 'litre':
|
| 12 |
-
default:
|
| 13 |
-
return 'L';
|
| 14 |
-
}
|
| 15 |
}
|
| 16 |
|
| 17 |
export const UNIT_OPTIONS: { value: ProductUnit; label: string }[] = [
|
| 18 |
{ value: 'litre', label: 'Litre (L)' },
|
| 19 |
{ value: 'kg', label: 'Kilogram (kg)' },
|
| 20 |
-
{ value: 'piece', label: 'Piece (
|
|
|
|
|
|
|
| 21 |
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import type { ProductUnit } from '@/api/types';
|
| 2 |
+
import { type Unit, normalizeUnit } from '@/lib/unit';
|
| 3 |
|
| 4 |
+
// Map the backend ProductUnit (litre|kg|piece|dozen|packet) to the canonical {@link Unit}
|
| 5 |
+
// the presentational library (ProductCard / Qty / NumberField adornment) consumes. The
|
| 6 |
+
// shared components accept `UnitLike`, so callers can also pass `product.unit` directly.
|
| 7 |
+
export function unitToken(unit: ProductUnit | undefined): Unit {
|
| 8 |
+
return normalizeUnit(unit);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
export const UNIT_OPTIONS: { value: ProductUnit; label: string }[] = [
|
| 12 |
{ value: 'litre', label: 'Litre (L)' },
|
| 13 |
{ value: 'kg', label: 'Kilogram (kg)' },
|
| 14 |
+
{ value: 'piece', label: 'Piece (pc)' },
|
| 15 |
+
{ value: 'dozen', label: 'Dozen (dz)' },
|
| 16 |
+
{ value: 'packet', label: 'Packet (pkt)' },
|
| 17 |
];
|
| 18 |
+
|
| 19 |
+
// Friendly singular name for a product's unit, used in product detail prose
|
| 20 |
+
// ("Sold by litre" / "Sold by piece"). Falls back to litre for unknown/undefined.
|
| 21 |
+
const UNIT_NAMES: Record<ProductUnit, string> = {
|
| 22 |
+
litre: 'litre',
|
| 23 |
+
kg: 'kilogram',
|
| 24 |
+
piece: 'piece',
|
| 25 |
+
dozen: 'dozen',
|
| 26 |
+
packet: 'packet',
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export function unitName(unit: ProductUnit | undefined): string {
|
| 30 |
+
return unit ? UNIT_NAMES[unit] : UNIT_NAMES.litre;
|
| 31 |
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Box, Card, LinearProgress, Stack, Typography } from '@mui/material';
|
| 2 |
+
import { Money, Qty } from '@/components';
|
| 3 |
+
import type { Unit } from '@/lib/unit';
|
| 4 |
+
import { type ProductSalesRow, productUnitMap, unitForRow } from '../lib/units';
|
| 5 |
+
import type { Product } from '@/api/types';
|
| 6 |
+
|
| 7 |
+
export interface ConsumptionBreakdownProps {
|
| 8 |
+
/** analytics `by_product[]` rows (each carries its own `unit` from the unit-aware API) */
|
| 9 |
+
rows: ProductSalesRow[];
|
| 10 |
+
/** live products list — fallback source for a row's unit when the row omits it */
|
| 11 |
+
products?: Product[];
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface ResolvedRow {
|
| 15 |
+
productId: number;
|
| 16 |
+
name: string;
|
| 17 |
+
qty: number;
|
| 18 |
+
unit: Unit;
|
| 19 |
+
amount: number;
|
| 20 |
+
pct: number;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// Per-product consumption breakdown for the customer detail page. Renders the
|
| 24 |
+
// unit-aware `by_product[]`: each line shows the product name, its quantity WITH ITS
|
| 25 |
+
// OWN unit (litres / kg / pieces — never cross-unit summed, never blanket "L"), the
|
| 26 |
+
// revenue (₹), and a revenue-share meter. Revenue is the cross-unit-safe headline; the
|
| 27 |
+
// per-product qty is only ever shown next to its own unit label.
|
| 28 |
+
export function ConsumptionBreakdown({ rows, products }: ConsumptionBreakdownProps) {
|
| 29 |
+
const fallback = productUnitMap(products);
|
| 30 |
+
const resolved: ResolvedRow[] = rows.map((r) => ({
|
| 31 |
+
productId: r.product_id,
|
| 32 |
+
name: r.product_name,
|
| 33 |
+
qty: r.qty,
|
| 34 |
+
unit: unitForRow(r, fallback),
|
| 35 |
+
amount: r.amount,
|
| 36 |
+
// pct_of_revenue is share of the WHOLE org's revenue, not this customer's basket;
|
| 37 |
+
// recompute a within-basket share so the meters read 0–100% for this customer.
|
| 38 |
+
pct: 0,
|
| 39 |
+
}));
|
| 40 |
+
|
| 41 |
+
const basketTotal = resolved.reduce((sum, r) => sum + Math.max(0, r.amount), 0);
|
| 42 |
+
for (const r of resolved) {
|
| 43 |
+
r.pct = basketTotal > 0 ? (Math.max(0, r.amount) / basketTotal) * 100 : 0;
|
| 44 |
+
}
|
| 45 |
+
// Highest spend first — the products that matter to this customer lead.
|
| 46 |
+
resolved.sort((a, b) => b.amount - a.amount);
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<Stack spacing={1.5}>
|
| 50 |
+
{resolved.map((r) => (
|
| 51 |
+
<Card key={r.productId} sx={{ p: 2 }}>
|
| 52 |
+
<Stack direction="row" alignItems="baseline" justifyContent="space-between" spacing={1}>
|
| 53 |
+
<Typography variant="subtitle1" noWrap sx={{ minWidth: 0, flex: 1 }}>
|
| 54 |
+
{r.name}
|
| 55 |
+
</Typography>
|
| 56 |
+
<Money value={r.amount} sx={{ typography: 'subtitle1', fontWeight: 700 }} />
|
| 57 |
+
</Stack>
|
| 58 |
+
|
| 59 |
+
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1} sx={{ mt: 0.5 }}>
|
| 60 |
+
<Typography variant="body2" color="text.secondary">
|
| 61 |
+
{/* qty rendered WITH its product's own unit — never a cross-unit sum */}
|
| 62 |
+
<Qty value={r.qty} unit={r.unit} component="span" /> total
|
| 63 |
+
</Typography>
|
| 64 |
+
<Typography variant="caption" color="text.secondary">
|
| 65 |
+
{Math.round(r.pct)}% of spend
|
| 66 |
+
</Typography>
|
| 67 |
+
</Stack>
|
| 68 |
+
|
| 69 |
+
<Box sx={{ mt: 1 }}>
|
| 70 |
+
<LinearProgress
|
| 71 |
+
variant="determinate"
|
| 72 |
+
value={Math.min(100, Math.max(0, r.pct))}
|
| 73 |
+
aria-label={`${r.name} share of spend`}
|
| 74 |
+
sx={(t) => ({
|
| 75 |
+
height: 6,
|
| 76 |
+
borderRadius: 999,
|
| 77 |
+
bgcolor: `${t.palette.primary.main}1A`,
|
| 78 |
+
'& .MuiLinearProgress-bar': { borderRadius: 999 },
|
| 79 |
+
})}
|
| 80 |
+
/>
|
| 81 |
+
</Box>
|
| 82 |
+
</Card>
|
| 83 |
+
))}
|
| 84 |
+
</Stack>
|
| 85 |
+
);
|
| 86 |
+
}
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useEffect, useState } from 'react';
|
| 2 |
-
import { useForm } from 'react-hook-form';
|
| 3 |
import { Box, Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
|
| 4 |
import DeleteOutline from '@mui/icons-material/DeleteOutline';
|
| 5 |
import {
|
|
@@ -22,6 +22,7 @@ import { useProducts } from '@/api/hooks/useProducts';
|
|
| 22 |
import { ApiError } from '@/api';
|
| 23 |
import type { Subscription, SubscriptionInput, Product } from '@/api/types';
|
| 24 |
import type { AppButtonState } from '@/components';
|
|
|
|
| 25 |
import { DAY_LABELS } from '../lib/format';
|
| 26 |
|
| 27 |
export interface SubscriptionFormSheetProps {
|
|
@@ -76,6 +77,17 @@ export function SubscriptionFormSheet({
|
|
| 76 |
defaultValues: emptyValues,
|
| 77 |
});
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
useEffect(() => {
|
| 80 |
if (!open) return;
|
| 81 |
if (subscription) {
|
|
@@ -197,8 +209,9 @@ export function SubscriptionFormSheet({
|
|
| 197 |
control={control}
|
| 198 |
label="Morning qty"
|
| 199 |
min={0}
|
| 200 |
-
step={
|
| 201 |
-
|
|
|
|
| 202 |
selectAllOnFocus
|
| 203 |
/>
|
| 204 |
</Box>
|
|
@@ -208,8 +221,9 @@ export function SubscriptionFormSheet({
|
|
| 208 |
control={control}
|
| 209 |
label="Evening qty"
|
| 210 |
min={0}
|
| 211 |
-
step={
|
| 212 |
-
|
|
|
|
| 213 |
selectAllOnFocus
|
| 214 |
/>
|
| 215 |
</Box>
|
|
|
|
| 1 |
import { useEffect, useState } from 'react';
|
| 2 |
+
import { useForm, useWatch } from 'react-hook-form';
|
| 3 |
import { Box, Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
|
| 4 |
import DeleteOutline from '@mui/icons-material/DeleteOutline';
|
| 5 |
import {
|
|
|
|
| 22 |
import { ApiError } from '@/api';
|
| 23 |
import type { Subscription, SubscriptionInput, Product } from '@/api/types';
|
| 24 |
import type { AppButtonState } from '@/components';
|
| 25 |
+
import { unitLabel, unitStep } from '@/lib/unit';
|
| 26 |
import { DAY_LABELS } from '../lib/format';
|
| 27 |
|
| 28 |
export interface SubscriptionFormSheetProps {
|
|
|
|
| 77 |
defaultValues: emptyValues,
|
| 78 |
});
|
| 79 |
|
| 80 |
+
// Drive the morning/evening qty fields from the SELECTED product's unit (canonical
|
| 81 |
+
// litre|kg|piece|dozen|packet). Count units (piece/dozen/packet) get the integer
|
| 82 |
+
// keypad + step 1 via NumberField's `qtyUnit`; kg/litre get their decimal step. The
|
| 83 |
+
// visual end-adornment shows the unit's short label (L/kg/pc/dz/pkt — never blank).
|
| 84 |
+
// Until a product is picked we fall back to litre, preserving the milk default.
|
| 85 |
+
const selectedProductId = useWatch({ control, name: 'product_id' });
|
| 86 |
+
const selectedProduct = products.find((p) => String(p.id) === String(selectedProductId));
|
| 87 |
+
const qtyUnit = selectedProduct?.unit ?? 'litre';
|
| 88 |
+
const qtyStep = unitStep(qtyUnit);
|
| 89 |
+
const qtyAdornment = unitLabel(qtyUnit);
|
| 90 |
+
|
| 91 |
useEffect(() => {
|
| 92 |
if (!open) return;
|
| 93 |
if (subscription) {
|
|
|
|
| 209 |
control={control}
|
| 210 |
label="Morning qty"
|
| 211 |
min={0}
|
| 212 |
+
step={qtyStep}
|
| 213 |
+
qtyUnit={qtyUnit}
|
| 214 |
+
unit={qtyAdornment}
|
| 215 |
selectAllOnFocus
|
| 216 |
/>
|
| 217 |
</Box>
|
|
|
|
| 221 |
control={control}
|
| 222 |
label="Evening qty"
|
| 223 |
min={0}
|
| 224 |
+
step={qtyStep}
|
| 225 |
+
qtyUnit={qtyUnit}
|
| 226 |
+
unit={qtyAdornment}
|
| 227 |
selectAllOnFocus
|
| 228 |
/>
|
| 229 |
</Box>
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Customer-feature unit helpers. The backend is now unit-aware and returns a `unit`
|
| 2 |
+
// on each analytics `by_product[]` row (ProductSales.unit) — but the shared
|
| 3 |
+
// `@/api/types` ProductSales type is owned by FE-components and may not yet declare
|
| 4 |
+
// it. We must NOT edit shared types from this feature folder, so we read the unit
|
| 5 |
+
// defensively here:
|
| 6 |
+
// 1. prefer the unit carried ON the analytics row (the snapshot the server returns),
|
| 7 |
+
// 2. else fall back to the product's current unit from the live products list,
|
| 8 |
+
// 3. else 'litre' (normalizeUnit's own default — never blank).
|
| 9 |
+
//
|
| 10 |
+
// Cross-unit quantity is NEVER summed or labelled "L": litres, kilograms and pieces
|
| 11 |
+
// are different physical dimensions. Revenue (₹) is the only safely-summable cross-unit
|
| 12 |
+
// measure, so the page headline prefers revenue and per-product rows each carry their
|
| 13 |
+
// own unit.
|
| 14 |
+
|
| 15 |
+
import type { Product, ProductSales, ProductUnit } from '@/api/types';
|
| 16 |
+
import { type Unit, normalizeUnit } from '@/lib/unit';
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* The analytics `by_product[]` row as the unit-aware backend actually sends it: the
|
| 20 |
+
* shared `ProductSales` shape plus the per-row `unit` snapshot. Declared locally
|
| 21 |
+
* (intersection, not a shared-type edit) so we can read `unit` without `any`.
|
| 22 |
+
*/
|
| 23 |
+
export type ProductSalesRow = ProductSales & { unit?: ProductUnit | string | null };
|
| 24 |
+
|
| 25 |
+
/** Build a product_id -> canonical unit lookup from the live products list. */
|
| 26 |
+
export function productUnitMap(products: Product[] | undefined): Map<number, Unit> {
|
| 27 |
+
const map = new Map<number, Unit>();
|
| 28 |
+
for (const p of products ?? []) map.set(p.id, normalizeUnit(p.unit));
|
| 29 |
+
return map;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Resolve the canonical unit for an analytics by_product row: prefer the unit the
|
| 34 |
+
* server stamped on the row, else the live product's unit, else 'litre'.
|
| 35 |
+
*/
|
| 36 |
+
export function unitForRow(row: ProductSalesRow, fallback: Map<number, Unit>): Unit {
|
| 37 |
+
if (row.unit) return normalizeUnit(row.unit);
|
| 38 |
+
return fallback.get(row.product_id) ?? normalizeUnit(undefined);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/** Resolve the canonical unit for a subscription's product from the live products list. */
|
| 42 |
+
export function unitForProduct(
|
| 43 |
+
productId: number,
|
| 44 |
+
fallback: Map<number, Unit>,
|
| 45 |
+
): Unit {
|
| 46 |
+
return fallback.get(productId) ?? normalizeUnit(undefined);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Map a canonical unit to the legacy `'L' | 'kg' | 'u'` token that `SubscriptionRow`
|
| 51 |
+
* (a shared component we must not edit) still accepts. The component forwards this to
|
| 52 |
+
* `<Qty>`, which re-normalizes it — so count units (piece/dozen/packet) collapse to the
|
| 53 |
+
* generic count token 'u'. This is lossy for dz/pkt at the SubscriptionRow level only;
|
| 54 |
+
* the richer per-product consumption breakdown keeps the exact unit label.
|
| 55 |
+
*/
|
| 56 |
+
export function legacyUnitToken(unit: Unit): 'L' | 'kg' | 'u' {
|
| 57 |
+
if (unit === 'kg') return 'kg';
|
| 58 |
+
if (unit === 'litre') return 'L';
|
| 59 |
+
return 'u'; // piece | dozen | packet
|
| 60 |
+
}
|
|
@@ -26,9 +26,7 @@ import {
|
|
| 26 |
IconAction,
|
| 27 |
AppButton,
|
| 28 |
MetricCard,
|
| 29 |
-
BarChart,
|
| 30 |
Money,
|
| 31 |
-
Qty,
|
| 32 |
SubscriptionRow,
|
| 33 |
LedgerList,
|
| 34 |
StatusChip,
|
|
@@ -45,12 +43,20 @@ import { hasPermission } from '@/auth/permissions';
|
|
| 45 |
import { useCustomer, useDeleteCustomer } from '@/api/hooks/useCustomers';
|
| 46 |
import { useAnalyticsCustomer } from '@/api/hooks/useAnalytics';
|
| 47 |
import { useSubscriptions } from '@/api/hooks/useSubscriptions';
|
|
|
|
| 48 |
import { useCustomerLedger, type LedgerRow } from '@/api/hooks/useCustomerLedger';
|
| 49 |
import type { Subscription } from '@/api/types';
|
| 50 |
import { useAppNav } from '../hooks/useAppNav';
|
| 51 |
import { useCustomerOutstanding } from '../hooks/useCustomerOutstanding';
|
| 52 |
import { CustomerFormSheet } from '../components/CustomerFormSheet';
|
| 53 |
import { SubscriptionFormSheet } from '../components/SubscriptionFormSheet';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
import { formatDays, telHref, whatsAppHref } from '../lib/format';
|
| 55 |
|
| 56 |
function billLabel(year: number, month: number): string {
|
|
@@ -88,8 +94,13 @@ function toLedgerEntry(row: LedgerRow, i: number): LedgerEntry {
|
|
| 88 |
}
|
| 89 |
|
| 90 |
// DESIGN_SYSTEM §9.2 + design-dashboard.md (b) — Customer detail: profile + quick actions,
|
| 91 |
-
// consumption (analytics/customer/{id}), product-
|
| 92 |
-
// subscriptions list (add/edit/delete), merged ledger; sticky "+ Add subscription".
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
export function CustomerDetailPage() {
|
| 94 |
const { id } = useParams<{ id: string }>();
|
| 95 |
const customerId = id ? Number(id) : undefined;
|
|
@@ -103,9 +114,20 @@ export function CustomerDetailPage() {
|
|
| 103 |
const ledgerQuery = useCustomerLedger(customerId);
|
| 104 |
const outstandingQuery = useCustomerOutstanding(customerId);
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
const customer = customerQuery.data;
|
| 107 |
const analytics = analyticsQuery.data;
|
| 108 |
const subs: Subscription[] = subsQuery.data?.items ?? [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
const menuAnchorRef = useRef<HTMLSpanElement>(null);
|
| 111 |
const [menuOpen, setMenuOpen] = useState(false);
|
|
@@ -123,11 +145,14 @@ export function CustomerDetailPage() {
|
|
| 123 |
[ledgerQuery.data],
|
| 124 |
);
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
const openAddSub = () => {
|
| 133 |
setEditingSub(null);
|
|
@@ -245,7 +270,9 @@ export function CustomerDetailPage() {
|
|
| 245 |
}
|
| 246 |
/>
|
| 247 |
|
| 248 |
-
{/* CONSUMPTION HEADLINE —
|
|
|
|
|
|
|
| 249 |
<Box
|
| 250 |
sx={{
|
| 251 |
display: 'grid',
|
|
@@ -254,21 +281,22 @@ export function CustomerDetailPage() {
|
|
| 254 |
}}
|
| 255 |
>
|
| 256 |
<MetricCard
|
| 257 |
-
icon={
|
| 258 |
-
label="
|
| 259 |
loading={analyticsQuery.isLoading}
|
| 260 |
error={analyticsQuery.isError}
|
| 261 |
onRetry={() => analyticsQuery.refetch()}
|
| 262 |
-
value={<
|
| 263 |
note={analytics ? `${analytics.days_served} days` : undefined}
|
| 264 |
/>
|
| 265 |
<MetricCard
|
| 266 |
-
icon={
|
| 267 |
-
label="
|
| 268 |
loading={analyticsQuery.isLoading}
|
| 269 |
error={analyticsQuery.isError}
|
| 270 |
onRetry={() => analyticsQuery.refetch()}
|
| 271 |
-
value={
|
|
|
|
| 272 |
/>
|
| 273 |
<MetricCard
|
| 274 |
icon={Insights}
|
|
@@ -276,20 +304,36 @@ export function CustomerDetailPage() {
|
|
| 276 |
loading={analyticsQuery.isLoading}
|
| 277 |
error={analyticsQuery.isError}
|
| 278 |
onRetry={() => analyticsQuery.refetch()}
|
| 279 |
-
value={
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
/>
|
| 282 |
</Box>
|
| 283 |
|
| 284 |
-
{/*
|
|
|
|
|
|
|
| 285 |
<Box>
|
| 286 |
<SectionHeader icon={WaterDrop} title="What they buy" />
|
| 287 |
{analyticsQuery.isLoading ? (
|
| 288 |
-
<Skeleton variant="
|
| 289 |
-
) :
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
<EmptyState variant="card" icon={WaterDrop} title="No deliveries yet" />
|
| 291 |
) : (
|
| 292 |
-
<
|
| 293 |
)}
|
| 294 |
</Box>
|
| 295 |
|
|
@@ -308,7 +352,8 @@ export function CustomerDetailPage() {
|
|
| 308 |
}
|
| 309 |
/>
|
| 310 |
|
| 311 |
-
{/* SUBSCRIPTIONS — list with add/edit/delete (writes gated)
|
|
|
|
| 312 |
<Box>
|
| 313 |
<SectionHeader icon={Inventory2} title="Subscriptions" />
|
| 314 |
{subsQuery.isLoading ? (
|
|
@@ -327,9 +372,10 @@ export function CustomerDetailPage() {
|
|
| 327 |
{subs.map((s) => (
|
| 328 |
<SubscriptionRow
|
| 329 |
key={s.id}
|
| 330 |
-
product={
|
| 331 |
morningQty={s.morning_qty}
|
| 332 |
eveningQty={s.evening_qty}
|
|
|
|
| 333 |
days={formatDays(s.days_of_week)}
|
| 334 |
active={s.active}
|
| 335 |
// edit/delete share the subscriptions.manage gate — hide the edit
|
|
|
|
| 26 |
IconAction,
|
| 27 |
AppButton,
|
| 28 |
MetricCard,
|
|
|
|
| 29 |
Money,
|
|
|
|
| 30 |
SubscriptionRow,
|
| 31 |
LedgerList,
|
| 32 |
StatusChip,
|
|
|
|
| 43 |
import { useCustomer, useDeleteCustomer } from '@/api/hooks/useCustomers';
|
| 44 |
import { useAnalyticsCustomer } from '@/api/hooks/useAnalytics';
|
| 45 |
import { useSubscriptions } from '@/api/hooks/useSubscriptions';
|
| 46 |
+
import { useProducts } from '@/api/hooks/useProducts';
|
| 47 |
import { useCustomerLedger, type LedgerRow } from '@/api/hooks/useCustomerLedger';
|
| 48 |
import type { Subscription } from '@/api/types';
|
| 49 |
import { useAppNav } from '../hooks/useAppNav';
|
| 50 |
import { useCustomerOutstanding } from '../hooks/useCustomerOutstanding';
|
| 51 |
import { CustomerFormSheet } from '../components/CustomerFormSheet';
|
| 52 |
import { SubscriptionFormSheet } from '../components/SubscriptionFormSheet';
|
| 53 |
+
import { ConsumptionBreakdown } from '../components/ConsumptionBreakdown';
|
| 54 |
+
import {
|
| 55 |
+
type ProductSalesRow,
|
| 56 |
+
legacyUnitToken,
|
| 57 |
+
productUnitMap,
|
| 58 |
+
unitForProduct,
|
| 59 |
+
} from '../lib/units';
|
| 60 |
import { formatDays, telHref, whatsAppHref } from '../lib/format';
|
| 61 |
|
| 62 |
function billLabel(year: number, month: number): string {
|
|
|
|
| 94 |
}
|
| 95 |
|
| 96 |
// DESIGN_SYSTEM §9.2 + design-dashboard.md (b) — Customer detail: profile + quick actions,
|
| 97 |
+
// consumption (analytics/customer/{id}), per-product unit-aware breakdown, computed
|
| 98 |
+
// outstanding, subscriptions list (add/edit/delete), merged ledger; sticky "+ Add subscription".
|
| 99 |
+
//
|
| 100 |
+
// UNIT-AWARENESS (SCENARIOS_UNITS D1/D5): a customer's basket can mix litres + kg + pieces,
|
| 101 |
+
// which are different physical dimensions and CANNOT be summed into one "X L" scalar. So the
|
| 102 |
+
// headline uses revenue (₹) — the only cross-unit-safe measure — and per-product quantity is
|
| 103 |
+
// only ever shown next to that product's own unit, via <ConsumptionBreakdown>.
|
| 104 |
export function CustomerDetailPage() {
|
| 105 |
const { id } = useParams<{ id: string }>();
|
| 106 |
const customerId = id ? Number(id) : undefined;
|
|
|
|
| 114 |
const ledgerQuery = useCustomerLedger(customerId);
|
| 115 |
const outstandingQuery = useCustomerOutstanding(customerId);
|
| 116 |
|
| 117 |
+
// Products carry the canonical unit (litre|kg|piece|dozen|packet). Used as the fallback
|
| 118 |
+
// unit source for subscription rows AND for any analytics by_product row that omits its
|
| 119 |
+
// own unit. Already cached by the products list elsewhere, so this is effectively free.
|
| 120 |
+
const productsQuery = useProducts();
|
| 121 |
+
|
| 122 |
const customer = customerQuery.data;
|
| 123 |
const analytics = analyticsQuery.data;
|
| 124 |
const subs: Subscription[] = subsQuery.data?.items ?? [];
|
| 125 |
+
const products = productsQuery.data?.items;
|
| 126 |
+
const productUnitById = useMemo(() => productUnitMap(products), [products]);
|
| 127 |
+
|
| 128 |
+
// The unit-aware backend stamps a `unit` on each by_product row; the shared ProductSales
|
| 129 |
+
// type may not declare it yet, so we widen locally (see ../lib/units).
|
| 130 |
+
const byProduct = (analytics?.by_product ?? []) as ProductSalesRow[];
|
| 131 |
|
| 132 |
const menuAnchorRef = useRef<HTMLSpanElement>(null);
|
| 133 |
const [menuOpen, setMenuOpen] = useState(false);
|
|
|
|
| 145 |
[ledgerQuery.data],
|
| 146 |
);
|
| 147 |
|
| 148 |
+
// Resolve a product NAME for a subscription: prefer the analytics by_product name (richer),
|
| 149 |
+
// else the live products list, else a stable placeholder.
|
| 150 |
+
const productNameById = useMemo(() => {
|
| 151 |
+
const m = new Map<number, string>();
|
| 152 |
+
for (const p of byProduct) m.set(p.product_id, p.product_name);
|
| 153 |
+
for (const p of products ?? []) if (!m.has(p.id)) m.set(p.id, p.name);
|
| 154 |
+
return m;
|
| 155 |
+
}, [byProduct, products]);
|
| 156 |
|
| 157 |
const openAddSub = () => {
|
| 158 |
setEditingSub(null);
|
|
|
|
| 270 |
}
|
| 271 |
/>
|
| 272 |
|
| 273 |
+
{/* CONSUMPTION HEADLINE — revenue-led (cross-unit-safe). Spend is the hero figure;
|
| 274 |
+
products count + days-served are the supporting metrics. We do NOT print a
|
| 275 |
+
cross-unit "total_qty L" headline — quantity lives per-product below. */}
|
| 276 |
<Box
|
| 277 |
sx={{
|
| 278 |
display: 'grid',
|
|
|
|
| 281 |
}}
|
| 282 |
>
|
| 283 |
<MetricCard
|
| 284 |
+
icon={Payments}
|
| 285 |
+
label="Spend"
|
| 286 |
loading={analyticsQuery.isLoading}
|
| 287 |
error={analyticsQuery.isError}
|
| 288 |
onRetry={() => analyticsQuery.refetch()}
|
| 289 |
+
value={<Money value={analytics?.total_amount ?? 0} countUp />}
|
| 290 |
note={analytics ? `${analytics.days_served} days` : undefined}
|
| 291 |
/>
|
| 292 |
<MetricCard
|
| 293 |
+
icon={Inventory2}
|
| 294 |
+
label="Products"
|
| 295 |
loading={analyticsQuery.isLoading}
|
| 296 |
error={analyticsQuery.isError}
|
| 297 |
onRetry={() => analyticsQuery.refetch()}
|
| 298 |
+
value={byProduct.length}
|
| 299 |
+
note="bought"
|
| 300 |
/>
|
| 301 |
<MetricCard
|
| 302 |
icon={Insights}
|
|
|
|
| 304 |
loading={analyticsQuery.isLoading}
|
| 305 |
error={analyticsQuery.isError}
|
| 306 |
onRetry={() => analyticsQuery.refetch()}
|
| 307 |
+
value={
|
| 308 |
+
<Money
|
| 309 |
+
value={
|
| 310 |
+
analytics && analytics.days_served > 0
|
| 311 |
+
? analytics.total_amount / analytics.days_served
|
| 312 |
+
: 0
|
| 313 |
+
}
|
| 314 |
+
/>
|
| 315 |
+
}
|
| 316 |
+
note="spend"
|
| 317 |
/>
|
| 318 |
</Box>
|
| 319 |
|
| 320 |
+
{/* WHAT THEY BUY — unit-aware per-product breakdown. Each row shows the product's
|
| 321 |
+
quantity with ITS OWN unit (L / kg / pc / dz / pkt) + revenue + spend share.
|
| 322 |
+
Replaces the old cross-unit "total_qty L" metric and the amount-only bar chart. */}
|
| 323 |
<Box>
|
| 324 |
<SectionHeader icon={WaterDrop} title="What they buy" />
|
| 325 |
{analyticsQuery.isLoading ? (
|
| 326 |
+
<Skeleton variant="row" count={3} />
|
| 327 |
+
) : analyticsQuery.isError ? (
|
| 328 |
+
<ErrorState
|
| 329 |
+
variant="card"
|
| 330 |
+
title="Couldn't load consumption"
|
| 331 |
+
onRetry={() => analyticsQuery.refetch()}
|
| 332 |
+
/>
|
| 333 |
+
) : byProduct.length === 0 ? (
|
| 334 |
<EmptyState variant="card" icon={WaterDrop} title="No deliveries yet" />
|
| 335 |
) : (
|
| 336 |
+
<ConsumptionBreakdown rows={byProduct} products={products} />
|
| 337 |
)}
|
| 338 |
</Box>
|
| 339 |
|
|
|
|
| 352 |
}
|
| 353 |
/>
|
| 354 |
|
| 355 |
+
{/* SUBSCRIPTIONS — list with add/edit/delete (writes gated). Each row's morning/
|
| 356 |
+
evening qty renders with the product's own unit, not a hardcoded 'L'. */}
|
| 357 |
<Box>
|
| 358 |
<SectionHeader icon={Inventory2} title="Subscriptions" />
|
| 359 |
{subsQuery.isLoading ? (
|
|
|
|
| 372 |
{subs.map((s) => (
|
| 373 |
<SubscriptionRow
|
| 374 |
key={s.id}
|
| 375 |
+
product={productNameById.get(s.product_id) ?? `Product #${s.product_id}`}
|
| 376 |
morningQty={s.morning_qty}
|
| 377 |
eveningQty={s.evening_qty}
|
| 378 |
+
unit={legacyUnitToken(unitForProduct(s.product_id, productUnitById))}
|
| 379 |
days={formatDays(s.days_of_week)}
|
| 380 |
active={s.active}
|
| 381 |
// edit/delete share the subscriptions.manage gate — hide the edit
|
|
@@ -22,6 +22,7 @@ import {
|
|
| 22 |
import type { Product, Customer, DeliveryInput, Shift } from '@/api/types';
|
| 23 |
import { useRecordDelivery, useDeleteDelivery } from '@/api/hooks';
|
| 24 |
import { ApiError } from '@/api/ApiError';
|
|
|
|
| 25 |
import type { DraftRow, DraftStatus } from '../hooks/useRouteDraft';
|
| 26 |
|
| 27 |
export type SheetMode =
|
|
@@ -58,8 +59,6 @@ const STATUS_OPTIONS = [
|
|
| 58 |
{ value: 'hold' as const, label: 'Hold', icon: <PauseCircle sx={{ fontSize: 16 }} /> },
|
| 59 |
];
|
| 60 |
|
| 61 |
-
const round1 = (n: number) => Math.round(n * 10) / 10;
|
| 62 |
-
|
| 63 |
// design-route.md Screen B — single record-delivery bottom sheet (edit / +extra / ad-hoc).
|
| 64 |
// Save → POST /deliveries (server resolves rate + amount). Delete recorded → DELETE /deliveries/{id}.
|
| 65 |
export function RecordDeliverySheet({
|
|
@@ -103,11 +102,24 @@ export function RecordDeliverySheet({
|
|
| 103 |
const status = watch('status');
|
| 104 |
const productId = watch('productId');
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
// Live amount preview (client estimate; the server is the source of truth on save).
|
| 107 |
const previewRate =
|
| 108 |
row && String(row.productId) === productId ? row.rate : 0; // unknown rate for a freshly picked product
|
| 109 |
const previewAmount =
|
| 110 |
-
status === 'delivered' ?
|
| 111 |
const qtyDisabled = status === 'skipped' || status === 'hold';
|
| 112 |
|
| 113 |
const productOptions = products
|
|
@@ -136,7 +148,8 @@ export function RecordDeliverySheet({
|
|
| 136 |
product_id: Number(values.productId),
|
| 137 |
delivery_date: date,
|
| 138 |
shift: values.shift,
|
| 139 |
-
|
|
|
|
| 140 |
status,
|
| 141 |
note: values.note?.trim() || undefined,
|
| 142 |
};
|
|
@@ -235,14 +248,14 @@ export function RecordDeliverySheet({
|
|
| 235 |
</Typography>
|
| 236 |
{row && !isExtra && row.plannedQty > 0 ? (
|
| 237 |
<Typography variant="caption" color="text.secondary">
|
| 238 |
-
Planned {row.plannedQty}
|
| 239 |
</Typography>
|
| 240 |
) : null}
|
| 241 |
</Stack>
|
| 242 |
<QtyStepper
|
| 243 |
value={Number(qty) || 0}
|
| 244 |
onChange={(q) => setValue('qty', q)}
|
| 245 |
-
unit=
|
| 246 |
disabled={qtyDisabled}
|
| 247 |
/>
|
| 248 |
</Box>
|
|
@@ -278,7 +291,7 @@ export function RecordDeliverySheet({
|
|
| 278 |
})}
|
| 279 |
>
|
| 280 |
<Typography variant="body2" color="text.secondary">
|
| 281 |
-
{previewRate > 0 ? `Rate ₹${previewRate}/
|
| 282 |
</Typography>
|
| 283 |
<Typography variant="subtitle1">
|
| 284 |
<Money value={previewAmount} countUp />
|
|
|
|
| 22 |
import type { Product, Customer, DeliveryInput, Shift } from '@/api/types';
|
| 23 |
import { useRecordDelivery, useDeleteDelivery } from '@/api/hooks';
|
| 24 |
import { ApiError } from '@/api/ApiError';
|
| 25 |
+
import { type Unit, formatQty, normalizeUnit, quantize, unitLabel } from '@/lib/unit';
|
| 26 |
import type { DraftRow, DraftStatus } from '../hooks/useRouteDraft';
|
| 27 |
|
| 28 |
export type SheetMode =
|
|
|
|
| 59 |
{ value: 'hold' as const, label: 'Hold', icon: <PauseCircle sx={{ fontSize: 16 }} /> },
|
| 60 |
];
|
| 61 |
|
|
|
|
|
|
|
| 62 |
// design-route.md Screen B — single record-delivery bottom sheet (edit / +extra / ad-hoc).
|
| 63 |
// Save → POST /deliveries (server resolves rate + amount). Delete recorded → DELETE /deliveries/{id}.
|
| 64 |
export function RecordDeliverySheet({
|
|
|
|
| 102 |
const status = watch('status');
|
| 103 |
const productId = watch('productId');
|
| 104 |
|
| 105 |
+
// Unit follows the selected product so the stepper/captions/quantize are correct for
|
| 106 |
+
// eggs (piece), ghee/paneer (kg), packs (packet) — not just milk (litre). Prefer the chosen
|
| 107 |
+
// product's unit; fall back to the edited row's unit; default 'litre' (legacy milk behaviour).
|
| 108 |
+
const selectedProduct = useMemo(
|
| 109 |
+
() => products.find((p) => String(p.id) === productId),
|
| 110 |
+
[products, productId],
|
| 111 |
+
);
|
| 112 |
+
const unit: Unit = selectedProduct
|
| 113 |
+
? normalizeUnit(selectedProduct.unit)
|
| 114 |
+
: row && String(row.productId) === productId
|
| 115 |
+
? row.unit
|
| 116 |
+
: 'litre';
|
| 117 |
+
|
| 118 |
// Live amount preview (client estimate; the server is the source of truth on save).
|
| 119 |
const previewRate =
|
| 120 |
row && String(row.productId) === productId ? row.rate : 0; // unknown rate for a freshly picked product
|
| 121 |
const previewAmount =
|
| 122 |
+
status === 'delivered' ? quantize(Number(qty) || 0, unit) * previewRate : 0;
|
| 123 |
const qtyDisabled = status === 'skipped' || status === 'hold';
|
| 124 |
|
| 125 |
const productOptions = products
|
|
|
|
| 148 |
product_id: Number(values.productId),
|
| 149 |
delivery_date: date,
|
| 150 |
shift: values.shift,
|
| 151 |
+
// Unit-aware quantize for the persisted value (integer counts / 3dp kg / litre step grid).
|
| 152 |
+
actual_qty: status === 'delivered' ? quantize(Number(values.qty) || 0, unit) : 0,
|
| 153 |
status,
|
| 154 |
note: values.note?.trim() || undefined,
|
| 155 |
};
|
|
|
|
| 248 |
</Typography>
|
| 249 |
{row && !isExtra && row.plannedQty > 0 ? (
|
| 250 |
<Typography variant="caption" color="text.secondary">
|
| 251 |
+
Planned {formatQty(row.plannedQty, row.unit)} {unitLabel(row.unit)}
|
| 252 |
</Typography>
|
| 253 |
) : null}
|
| 254 |
</Stack>
|
| 255 |
<QtyStepper
|
| 256 |
value={Number(qty) || 0}
|
| 257 |
onChange={(q) => setValue('qty', q)}
|
| 258 |
+
unit={unit}
|
| 259 |
disabled={qtyDisabled}
|
| 260 |
/>
|
| 261 |
</Box>
|
|
|
|
| 291 |
})}
|
| 292 |
>
|
| 293 |
<Typography variant="body2" color="text.secondary">
|
| 294 |
+
{previewRate > 0 ? `Rate ₹${previewRate}/${unitLabel(unit)}` : 'Rate resolved on save'}
|
| 295 |
</Typography>
|
| 296 |
<Typography variant="subtitle1">
|
| 297 |
<Money value={previewAmount} countUp />
|
|
@@ -3,6 +3,7 @@ import { motion as fm } from 'framer-motion';
|
|
| 3 |
import type { SvgIconComponent } from '@mui/icons-material';
|
| 4 |
import { DeliveryRow } from '@/components';
|
| 5 |
import { useReducedMotion } from '@/hooks/useReducedMotion';
|
|
|
|
| 6 |
import { sec, stagger } from '@/theme/motion';
|
| 7 |
import type { DraftRow, DraftStatus } from '../hooks/useRouteDraft';
|
| 8 |
|
|
@@ -20,8 +21,6 @@ export interface RouteSectionProps {
|
|
| 20 |
onExtra: (row: DraftRow) => void;
|
| 21 |
}
|
| 22 |
|
| 23 |
-
const round1 = (n: number) => Math.round(n * 10) / 10;
|
| 24 |
-
|
| 25 |
// design-route.md Screen A — grouped morning/evening section. Rows stagger-in (40ms).
|
| 26 |
export function RouteSection({
|
| 27 |
label,
|
|
@@ -78,11 +77,11 @@ export function RouteSection({
|
|
| 78 |
productName: row.productName,
|
| 79 |
status: row.status,
|
| 80 |
qty: row.qty,
|
| 81 |
-
unit:
|
| 82 |
rate: row.rate,
|
| 83 |
amount:
|
| 84 |
-
row.status === 'delivered' ?
|
| 85 |
-
extra: row.adHoc ?
|
| 86 |
note: row.note,
|
| 87 |
}}
|
| 88 |
dirty={row.dirty}
|
|
|
|
| 3 |
import type { SvgIconComponent } from '@mui/icons-material';
|
| 4 |
import { DeliveryRow } from '@/components';
|
| 5 |
import { useReducedMotion } from '@/hooks/useReducedMotion';
|
| 6 |
+
import { quantize } from '@/lib/unit';
|
| 7 |
import { sec, stagger } from '@/theme/motion';
|
| 8 |
import type { DraftRow, DraftStatus } from '../hooks/useRouteDraft';
|
| 9 |
|
|
|
|
| 21 |
onExtra: (row: DraftRow) => void;
|
| 22 |
}
|
| 23 |
|
|
|
|
|
|
|
| 24 |
// design-route.md Screen A — grouped morning/evening section. Rows stagger-in (40ms).
|
| 25 |
export function RouteSection({
|
| 26 |
label,
|
|
|
|
| 77 |
productName: row.productName,
|
| 78 |
status: row.status,
|
| 79 |
qty: row.qty,
|
| 80 |
+
unit: row.unit,
|
| 81 |
rate: row.rate,
|
| 82 |
amount:
|
| 83 |
+
row.status === 'delivered' ? quantize(row.qty, row.unit) * row.rate : undefined,
|
| 84 |
+
extra: row.adHoc ? quantize(row.qty, row.unit) : undefined,
|
| 85 |
note: row.note,
|
| 86 |
}}
|
| 87 |
dirty={row.dirty}
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useMemo } from 'react';
|
| 2 |
import { Box, Divider, Stack, Typography } from '@mui/material';
|
| 3 |
import { AppButton, BottomSheet, Money, StatusChip, type AppButtonState } from '@/components';
|
|
|
|
| 4 |
import type { DraftRow } from '../hooks/useRouteDraft';
|
| 5 |
|
| 6 |
export interface SubmitConfirmSheetProps {
|
|
@@ -14,8 +15,6 @@ export interface SubmitConfirmSheetProps {
|
|
| 14 |
onConfirm: () => void;
|
| 15 |
}
|
| 16 |
|
| 17 |
-
const round1 = (n: number) => Math.round(n * 10) / 10;
|
| 18 |
-
|
| 19 |
// design-route.md Screen C — submit confirmation summary (by status) before POST /deliveries/bulk.
|
| 20 |
export function SubmitConfirmSheet({
|
| 21 |
open,
|
|
@@ -28,21 +27,29 @@ export function SubmitConfirmSheet({
|
|
| 28 |
}: SubmitConfirmSheetProps) {
|
| 29 |
const summary = useMemo(() => {
|
| 30 |
let deliveredCount = 0;
|
| 31 |
-
let deliveredQty = 0;
|
| 32 |
let skipped = 0;
|
| 33 |
let hold = 0;
|
| 34 |
let extras = 0;
|
| 35 |
let billable = 0;
|
|
|
|
|
|
|
|
|
|
| 36 |
for (const r of dirtyRows) {
|
| 37 |
if (r.adHoc || r.plannedQty === 0) extras += 1;
|
| 38 |
if (r.status === 'delivered') {
|
| 39 |
deliveredCount += 1;
|
| 40 |
-
|
| 41 |
-
|
|
|
|
| 42 |
} else if (r.status === 'skipped') skipped += 1;
|
| 43 |
else if (r.status === 'hold') hold += 1;
|
| 44 |
}
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}, [dirtyRows]);
|
| 47 |
|
| 48 |
return (
|
|
@@ -69,7 +76,7 @@ export function SubmitConfirmSheet({
|
|
| 69 |
</Typography>
|
| 70 |
|
| 71 |
<Stack spacing={1.25}>
|
| 72 |
-
<Row label={<StatusChip status="delivered" />} value={`${summary.deliveredCount} · ${summary.
|
| 73 |
<Row label={<StatusChip status="skipped" />} value={String(summary.skipped)} />
|
| 74 |
<Row label={<StatusChip status="hold" />} value={String(summary.hold)} />
|
| 75 |
<Row
|
|
|
|
| 1 |
import { useMemo } from 'react';
|
| 2 |
import { Box, Divider, Stack, Typography } from '@mui/material';
|
| 3 |
import { AppButton, BottomSheet, Money, StatusChip, type AppButtonState } from '@/components';
|
| 4 |
+
import { UNIT, type Unit, formatQty, quantize, unitLabel } from '@/lib/unit';
|
| 5 |
import type { DraftRow } from '../hooks/useRouteDraft';
|
| 6 |
|
| 7 |
export interface SubmitConfirmSheetProps {
|
|
|
|
| 15 |
onConfirm: () => void;
|
| 16 |
}
|
| 17 |
|
|
|
|
|
|
|
| 18 |
// design-route.md Screen C — submit confirmation summary (by status) before POST /deliveries/bulk.
|
| 19 |
export function SubmitConfirmSheet({
|
| 20 |
open,
|
|
|
|
| 27 |
}: SubmitConfirmSheetProps) {
|
| 28 |
const summary = useMemo(() => {
|
| 29 |
let deliveredCount = 0;
|
|
|
|
| 30 |
let skipped = 0;
|
| 31 |
let hold = 0;
|
| 32 |
let extras = 0;
|
| 33 |
let billable = 0;
|
| 34 |
+
// Quantity is only summable WITHIN a unit — accumulate per unit so 200 L milk + 5 kg ghee +
|
| 35 |
+
// 120 eggs reads "200 L · 5 kg · 120 pc", never a meaningless cross-unit "325 L".
|
| 36 |
+
const qtyByUnit = new Map<Unit, number>();
|
| 37 |
for (const r of dirtyRows) {
|
| 38 |
if (r.adHoc || r.plannedQty === 0) extras += 1;
|
| 39 |
if (r.status === 'delivered') {
|
| 40 |
deliveredCount += 1;
|
| 41 |
+
const q = quantize(r.qty, r.unit);
|
| 42 |
+
qtyByUnit.set(r.unit, (qtyByUnit.get(r.unit) ?? 0) + q);
|
| 43 |
+
billable += q * r.rate;
|
| 44 |
} else if (r.status === 'skipped') skipped += 1;
|
| 45 |
else if (r.status === 'hold') hold += 1;
|
| 46 |
}
|
| 47 |
+
// Stable unit order; only units that actually appear.
|
| 48 |
+
const deliveredQtyText =
|
| 49 |
+
UNIT.filter((u) => qtyByUnit.has(u))
|
| 50 |
+
.map((u) => `${formatQty(qtyByUnit.get(u) as number, u)} ${unitLabel(u)}`)
|
| 51 |
+
.join(' · ') || '0';
|
| 52 |
+
return { deliveredCount, deliveredQtyText, skipped, hold, extras, billable };
|
| 53 |
}, [dirtyRows]);
|
| 54 |
|
| 55 |
return (
|
|
|
|
| 76 |
</Typography>
|
| 77 |
|
| 78 |
<Stack spacing={1.25}>
|
| 79 |
+
<Row label={<StatusChip status="delivered" />} value={`${summary.deliveredCount} · ${summary.deliveredQtyText}`} />
|
| 80 |
<Row label={<StatusChip status="skipped" />} value={String(summary.skipped)} />
|
| 81 |
<Row label={<StatusChip status="hold" />} value={String(summary.hold)} />
|
| 82 |
<Row
|
|
@@ -1,5 +1,12 @@
|
|
| 1 |
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
-
import type { RouteRow } from '@/api/types';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
// design-route.md Screen A/C — the route LIST is the bulk editor. Edits accumulate in a
|
| 5 |
// local "dirty" draft keyed by the row slot (customer+product+shift); nothing is POSTed per
|
|
@@ -21,6 +28,8 @@ export interface DraftRow {
|
|
| 21 |
plannedQty: number;
|
| 22 |
/** working quantity (server-zeroed for skip/hold on save) */
|
| 23 |
qty: number;
|
|
|
|
|
|
|
| 24 |
rate: number;
|
| 25 |
status: DraftStatus;
|
| 26 |
/** already saved on the server (✓ + amount); excluded from Submit unless edited */
|
|
@@ -49,6 +58,7 @@ function rowFromProjection(r: RouteRow): DraftRow {
|
|
| 49 |
shift: r.shift,
|
| 50 |
plannedQty: r.planned_qty,
|
| 51 |
qty,
|
|
|
|
| 52 |
rate: r.rate,
|
| 53 |
status,
|
| 54 |
recorded,
|
|
@@ -87,8 +97,6 @@ function persistOverlay(date: string, overlay: OverlayMap) {
|
|
| 87 |
}
|
| 88 |
}
|
| 89 |
|
| 90 |
-
const round1 = (n: number) => Math.round(n * 10) / 10;
|
| 91 |
-
|
| 92 |
/**
|
| 93 |
* Merges the server projection with a localStorage-backed overlay of unsaved edits and
|
| 94 |
* exposes mutators + derived totals. Read-only (future date) callers can ignore the mutators.
|
|
@@ -136,7 +144,18 @@ export function useRouteDraft(date: string, rows: RouteRow[] | undefined, readOn
|
|
| 136 |
[readOnly],
|
| 137 |
);
|
| 138 |
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
const setStatus = useCallback(
|
| 141 |
(key: string, status: DraftStatus) => patchRow(key, { status }),
|
| 142 |
[patchRow],
|
|
@@ -161,10 +180,12 @@ export function useRouteDraft(date: string, rows: RouteRow[] | undefined, readOn
|
|
| 161 |
|
| 162 |
// Derived totals (live count-up source).
|
| 163 |
const dirtyRows = useMemo(() => draftRows.filter((r) => r.dirty), [draftRows]);
|
|
|
|
|
|
|
| 164 |
const runningTotal = useMemo(
|
| 165 |
() =>
|
| 166 |
draftRows.reduce(
|
| 167 |
-
(sum, r) => (r.status === 'delivered' ? sum +
|
| 168 |
0,
|
| 169 |
),
|
| 170 |
[draftRows],
|
|
|
|
| 1 |
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
+
import type { ProductUnit, RouteRow } from '@/api/types';
|
| 3 |
+
import { type Unit, normalizeUnit, quantize } from '@/lib/unit';
|
| 4 |
+
|
| 5 |
+
// The backend now returns the product unit on each /route row ("unit"). The shared RouteRow
|
| 6 |
+
// type may not declare it yet; read it tolerantly without editing the shared API type. Unknown /
|
| 7 |
+
// missing values normalize to 'litre' (legacy milk behaviour) via normalizeUnit().
|
| 8 |
+
type RouteRowWithUnit = RouteRow & { unit?: ProductUnit };
|
| 9 |
+
const rowUnit = (r: RouteRow): Unit => normalizeUnit((r as RouteRowWithUnit).unit);
|
| 10 |
|
| 11 |
// design-route.md Screen A/C — the route LIST is the bulk editor. Edits accumulate in a
|
| 12 |
// local "dirty" draft keyed by the row slot (customer+product+shift); nothing is POSTed per
|
|
|
|
| 28 |
plannedQty: number;
|
| 29 |
/** working quantity (server-zeroed for skip/hold on save) */
|
| 30 |
qty: number;
|
| 31 |
+
/** product unit (litre|kg|piece|dozen|packet) — drives stepper/captions/quantize */
|
| 32 |
+
unit: Unit;
|
| 33 |
rate: number;
|
| 34 |
status: DraftStatus;
|
| 35 |
/** already saved on the server (✓ + amount); excluded from Submit unless edited */
|
|
|
|
| 58 |
shift: r.shift,
|
| 59 |
plannedQty: r.planned_qty,
|
| 60 |
qty,
|
| 61 |
+
unit: rowUnit(r),
|
| 62 |
rate: r.rate,
|
| 63 |
status,
|
| 64 |
recorded,
|
|
|
|
| 97 |
}
|
| 98 |
}
|
| 99 |
|
|
|
|
|
|
|
| 100 |
/**
|
| 101 |
* Merges the server projection with a localStorage-backed overlay of unsaved edits and
|
| 102 |
* exposes mutators + derived totals. Read-only (future date) callers can ignore the mutators.
|
|
|
|
| 144 |
[readOnly],
|
| 145 |
);
|
| 146 |
|
| 147 |
+
// Per-slot unit lookup so qty mutations quantize against the right unit (integer for
|
| 148 |
+
// piece/dozen/packet, 3dp for kg, step grid for litre) — never the legacy 1-decimal round1.
|
| 149 |
+
const unitByKey = useMemo(() => {
|
| 150 |
+
const m = new Map<string, Unit>();
|
| 151 |
+
for (const r of draftRows) m.set(r.key, r.unit);
|
| 152 |
+
return m;
|
| 153 |
+
}, [draftRows]);
|
| 154 |
+
|
| 155 |
+
const setQty = useCallback(
|
| 156 |
+
(key: string, qty: number) => patchRow(key, { qty: quantize(qty, unitByKey.get(key)) }),
|
| 157 |
+
[patchRow, unitByKey],
|
| 158 |
+
);
|
| 159 |
const setStatus = useCallback(
|
| 160 |
(key: string, status: DraftStatus) => patchRow(key, { status }),
|
| 161 |
[patchRow],
|
|
|
|
| 180 |
|
| 181 |
// Derived totals (live count-up source).
|
| 182 |
const dirtyRows = useMemo(() => draftRows.filter((r) => r.dirty), [draftRows]);
|
| 183 |
+
// Client preview only; the server recomputes amount on submit. Quantize per the row's unit
|
| 184 |
+
// so a 0.125 kg portion previews ₹50 (not the round1 ₹40) and counts stay whole.
|
| 185 |
const runningTotal = useMemo(
|
| 186 |
() =>
|
| 187 |
draftRows.reduce(
|
| 188 |
+
(sum, r) => (r.status === 'delivered' ? sum + quantize(r.qty, r.unit) * r.rate : sum),
|
| 189 |
0,
|
| 190 |
),
|
| 191 |
[draftRows],
|
|
@@ -23,6 +23,7 @@ import { AppShell, Fab, NAV_TABS } from '@/app';
|
|
| 23 |
import { useRoute, useSubmitDeliveries, useProducts, useCustomers } from '@/api/hooks';
|
| 24 |
import { useAuth } from '@/api/hooks/useAuth';
|
| 25 |
import { ApiError } from '@/api/ApiError';
|
|
|
|
| 26 |
import type { DeliveryInput } from '@/api/types';
|
| 27 |
import { useRouteDraft, type DraftRow } from '../hooks/useRouteDraft';
|
| 28 |
import { useOnline } from '../hooks/useOnline';
|
|
@@ -91,7 +92,9 @@ export function RoutePage() {
|
|
| 91 |
product_id: r.productId,
|
| 92 |
delivery_date: date,
|
| 93 |
shift: r.shift,
|
| 94 |
-
|
|
|
|
|
|
|
| 95 |
status: r.status === 'pending' ? 'delivered' : r.status,
|
| 96 |
note: r.note?.trim() || undefined,
|
| 97 |
}));
|
|
|
|
| 23 |
import { useRoute, useSubmitDeliveries, useProducts, useCustomers } from '@/api/hooks';
|
| 24 |
import { useAuth } from '@/api/hooks/useAuth';
|
| 25 |
import { ApiError } from '@/api/ApiError';
|
| 26 |
+
import { quantize } from '@/lib/unit';
|
| 27 |
import type { DeliveryInput } from '@/api/types';
|
| 28 |
import { useRouteDraft, type DraftRow } from '../hooks/useRouteDraft';
|
| 29 |
import { useOnline } from '../hooks/useOnline';
|
|
|
|
| 92 |
product_id: r.productId,
|
| 93 |
delivery_date: date,
|
| 94 |
shift: r.shift,
|
| 95 |
+
// Unit-aware quantize for the persisted value: integer for piece/dozen/packet, 3dp for
|
| 96 |
+
// kg (gram precision the server keeps), step grid for litre. Never the legacy round1.
|
| 97 |
+
actual_qty: r.status === 'delivered' ? quantize(r.qty, r.unit) : 0,
|
| 98 |
status: r.status === 'pending' ? 'delivered' : r.status,
|
| 99 |
note: r.note?.trim() || undefined,
|
| 100 |
}));
|
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Shared, unit-aware quantity utility. The backend is the source of truth for the
|
| 2 |
+
// canonical unit names: products.unit ∈ litre|kg|piece|dozen|packet. The server keeps
|
| 3 |
+
// 3dp for kg/litre and rejects fractional qty for piece/dozen/packet (count units).
|
| 4 |
+
//
|
| 5 |
+
// This module is the SINGLE place that knows, per unit:
|
| 6 |
+
// - the stepper step / min
|
| 7 |
+
// - how many decimals to show
|
| 8 |
+
// - which mobile keypad to raise (numeric vs decimal)
|
| 9 |
+
// - the short display label (NEVER blank)
|
| 10 |
+
// - how to quantize a value before it is sent to the server
|
| 11 |
+
//
|
| 12 |
+
// It accepts both the canonical backend names AND the legacy short presentational
|
| 13 |
+
// tokens ('L'|'kg'|'u') the component library historically used, so existing call
|
| 14 |
+
// sites keep working unchanged. Unknown / undefined inputs fall back to 'litre'.
|
| 15 |
+
|
| 16 |
+
/** Canonical backend unit names. */
|
| 17 |
+
export type Unit = 'litre' | 'kg' | 'piece' | 'dozen' | 'packet';
|
| 18 |
+
|
| 19 |
+
/** Anything we'll accept and normalize: canonical names, legacy tokens, or undefined. */
|
| 20 |
+
export type UnitLike = Unit | 'L' | 'u' | (string & {}) | undefined | null;
|
| 21 |
+
|
| 22 |
+
export const UNIT: readonly Unit[] = ['litre', 'kg', 'piece', 'dozen', 'packet'];
|
| 23 |
+
|
| 24 |
+
/** Count units take whole numbers only (no fractional eggs / packs / dozens). */
|
| 25 |
+
const COUNT_UNITS: ReadonlySet<Unit> = new Set<Unit>(['piece', 'dozen', 'packet']);
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Normalize any accepted input to a canonical {@link Unit}. Legacy short tokens map:
|
| 29 |
+
* 'L' -> litre, 'u' -> piece, 'kg' -> kg. Unknown / undefined -> 'litre'.
|
| 30 |
+
*/
|
| 31 |
+
export function normalizeUnit(unit: UnitLike): Unit {
|
| 32 |
+
switch (unit) {
|
| 33 |
+
case 'kg':
|
| 34 |
+
return 'kg';
|
| 35 |
+
case 'piece':
|
| 36 |
+
case 'u':
|
| 37 |
+
return 'piece';
|
| 38 |
+
case 'dozen':
|
| 39 |
+
return 'dozen';
|
| 40 |
+
case 'packet':
|
| 41 |
+
return 'packet';
|
| 42 |
+
case 'litre':
|
| 43 |
+
case 'L':
|
| 44 |
+
default:
|
| 45 |
+
return 'litre';
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/** True when the unit is counted in whole numbers (piece / dozen / packet). */
|
| 50 |
+
export function isCountUnit(unit: UnitLike): boolean {
|
| 51 |
+
return COUNT_UNITS.has(normalizeUnit(unit));
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/** Stepper increment: litre 0.5, kg 0.25, count units 1. */
|
| 55 |
+
export function unitStep(unit: UnitLike): number {
|
| 56 |
+
const u = normalizeUnit(unit);
|
| 57 |
+
if (u === 'kg') return 0.25;
|
| 58 |
+
if (isCountUnit(u)) return 1;
|
| 59 |
+
return 0.5; // litre
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/** Minimum quantity (0 for all units today; kept as a hook for future deposit/min-order). */
|
| 63 |
+
export function unitMin(_unit: UnitLike): number {
|
| 64 |
+
return 0;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/** Decimal places to DISPLAY: count 0, kg up to 3, litre up to 2 (trailing trimmed by callers). */
|
| 68 |
+
export function unitDecimals(unit: UnitLike): number {
|
| 69 |
+
const u = normalizeUnit(unit);
|
| 70 |
+
if (isCountUnit(u)) return 0;
|
| 71 |
+
if (u === 'kg') return 3;
|
| 72 |
+
return 2; // litre (0.5 / 1.5 / 0.25)
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Mobile keypad mode. Count units raise the integer-only pad ('numeric', no dot);
|
| 77 |
+
* weight/volume raise the decimal pad.
|
| 78 |
+
*/
|
| 79 |
+
export function unitInputMode(unit: UnitLike): 'numeric' | 'decimal' {
|
| 80 |
+
return isCountUnit(unit) ? 'numeric' : 'decimal';
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/** HTML input `pattern` matching {@link unitInputMode} — digits only for count units. */
|
| 84 |
+
export function unitPattern(unit: UnitLike): string {
|
| 85 |
+
return isCountUnit(unit) ? '[0-9]*' : '[0-9]*[.]?[0-9]*';
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/** Short display label — NEVER blank. litre 'L', kg 'kg', piece 'pc', dozen 'dz', packet 'pkt'. */
|
| 89 |
+
export function unitLabel(unit: UnitLike): string {
|
| 90 |
+
switch (normalizeUnit(unit)) {
|
| 91 |
+
case 'kg':
|
| 92 |
+
return 'kg';
|
| 93 |
+
case 'piece':
|
| 94 |
+
return 'pc';
|
| 95 |
+
case 'dozen':
|
| 96 |
+
return 'dz';
|
| 97 |
+
case 'packet':
|
| 98 |
+
return 'pkt';
|
| 99 |
+
case 'litre':
|
| 100 |
+
default:
|
| 101 |
+
return 'L';
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* Quantize a value to the precision the server expects for this unit, so the value SENT
|
| 107 |
+
* to the server keeps kg gram-precision and is a whole number for counts:
|
| 108 |
+
* - count (piece/dozen/packet) -> nearest integer
|
| 109 |
+
* - kg -> 3 decimal places (gram precision)
|
| 110 |
+
* - litre -> 2 decimal places (preserves 0.25 / 0.5 / 0.75)
|
| 111 |
+
* Non-finite input quantizes to {@link unitMin}.
|
| 112 |
+
* Note: litre rounds to 2dp rather than snapping to the 0.5 stepper grid, so a typed
|
| 113 |
+
* quarter-litre (0.25 L — common for milk) is preserved; the +/- stepper still moves by 0.5.
|
| 114 |
+
*/
|
| 115 |
+
export function quantize(value: number, unit: UnitLike): number {
|
| 116 |
+
const u = normalizeUnit(unit);
|
| 117 |
+
if (!Number.isFinite(value)) return unitMin(u);
|
| 118 |
+
if (isCountUnit(u)) return Math.round(value);
|
| 119 |
+
if (u === 'kg') return Math.round(value * 1000) / 1000; // 3dp
|
| 120 |
+
return Math.round(value * 100) / 100; // litre: 2dp
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Format a quantity for display per its unit: integers for count units, trailing zeros
|
| 125 |
+
* trimmed for kg/litre, capped at {@link unitDecimals}. Does NOT append the label.
|
| 126 |
+
*/
|
| 127 |
+
export function formatQty(value: number, unit: UnitLike): string {
|
| 128 |
+
const u = normalizeUnit(unit);
|
| 129 |
+
if (isCountUnit(u)) return String(Math.round(value));
|
| 130 |
+
if (Number.isInteger(value)) return String(value);
|
| 131 |
+
// Up to N decimals, trailing zeros trimmed.
|
| 132 |
+
return value.toFixed(unitDecimals(u)).replace(/\.?0+$/, '');
|
| 133 |
+
}
|
|
@@ -1 +1 @@
|
|
| 1 |
-
{"root":["./src/approutes.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/apierror.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/index.ts","./src/api/keys.ts","./src/api/types.ts","./src/api/hooks/index.ts","./src/api/hooks/useanalytics.ts","./src/api/hooks/useauth.ts","./src/api/hooks/usebills.ts","./src/api/hooks/usecustomerledger.ts","./src/api/hooks/usecustomers.ts","./src/api/hooks/usepayments.ts","./src/api/hooks/useprices.ts","./src/api/hooks/useproducts.ts","./src/api/hooks/useroute.ts","./src/api/hooks/usesettings.ts","./src/api/hooks/usesubscriptions.ts","./src/api/hooks/useusers.ts","./src/app/appshell.tsx","./src/app/bottomnav.tsx","./src/app/fab.tsx","./src/app/pagetransition.tsx","./src/app/stickyactionbar.tsx","./src/app/stickyheader.tsx","./src/app/index.ts","./src/app/navconfig.tsx","./src/app/queryclient.ts","./src/auth/authstore.ts","./src/auth/permissions.ts","./src/components/index.ts","./src/components/buttons/appbutton.tsx","./src/components/buttons/iconaction.tsx","./src/components/charts/barchart.tsx","./src/components/charts/chartframe.tsx","./src/components/charts/donutchart.tsx","./src/components/charts/metriccard.tsx","./src/components/charts/trendchart.tsx","./src/components/domain/billcard.tsx","./src/components/domain/billlinestable.tsx","./src/components/domain/customercard.tsx","./src/components/domain/deliveryrow.tsx","./src/components/domain/ledgerlist.tsx","./src/components/domain/paymentrow.tsx","./src/components/domain/productcard.tsx","./src/components/domain/qrviewer.tsx","./src/components/domain/subscriptionrow.tsx","./src/components/domain/whatsappsharebutton.tsx","./src/components/domain/types.ts","./src/components/feedback/emptystate.tsx","./src/components/feedback/errorboundary.tsx","./src/components/feedback/errorstate.tsx","./src/components/feedback/skeleton.tsx","./src/components/feedback/spinner.tsx","./src/components/feedback/toastprovider.tsx","./src/components/fields/datefield.tsx","./src/components/fields/keyboardawarefield.tsx","./src/components/fields/numberfield.tsx","./src/components/fields/phonefield.tsx","./src/components/fields/qtystepper.tsx","./src/components/fields/searchbar.tsx","./src/components/fields/selectfield.tsx","./src/components/fields/statustoggle.tsx","./src/components/fields/switchfield.tsx","./src/components/fields/textfield.tsx","./src/components/format/datelabel.tsx","./src/components/format/money.tsx","./src/components/format/qty.tsx","./src/components/gating/permissiongate.tsx","./src/components/gating/rolegate.tsx","./src/components/lists/pagedlist.tsx","./src/components/lists/usepulltorefresh.ts","./src/components/nav/datestrip.tsx","./src/components/nav/pagetabs.tsx","./src/components/nav/stickytotalbar.tsx","./src/components/overlays/bottomsheet.tsx","./src/components/overlays/confirmdialog.tsx","./src/components/overlays/modal.tsx","./src/components/status/statuschip.tsx","./src/features/admin/settingspage.tsx","./src/features/admin/staffpage.tsx","./src/features/admin/routes.tsx","./src/features/admin/useadminnav.ts","./src/features/admin/components/createemployeesheet.tsx","./src/features/admin/components/holidayssection.tsx","./src/features/admin/components/permissionlabels.ts","./src/features/admin/components/permissionssheet.tsx","./src/features/admin/components/staffrow.tsx","./src/features/analytics/dashboardpage.tsx","./src/features/analytics/daterangesheet.tsx","./src/features/analytics/rangecontrol.tsx","./src/features/analytics/reportspage.tsx","./src/features/analytics/index.ts","./src/features/analytics/routes.tsx","./src/features/analytics/usedaterange.ts","./src/features/auth/routes.tsx","./src/features/auth/pages/loginpage.tsx","./src/features/billing/billdetailpage.tsx","./src/features/billing/billingpage.tsx","./src/features/billing/index.ts","./src/features/billing/routes.tsx","./src/features/billing/components/adjustmentsheet.tsx","./src/features/billing/components/billslisttab.tsx","./src/features/billing/components/generatetab.tsx","./src/features/billing/components/recordpaymentsheet.tsx","./src/features/billing/components/statuschangesheet.tsx","./src/features/billing/hooks/usebillingshell.ts","./src/features/billing/hooks/usegeneratebills.ts","./src/features/billing/hooks/usegeneratepreview.ts","./src/features/catalog/routes.tsx","./src/features/catalog/unit.ts","./src/features/catalog/usecatalognav.ts","./src/features/catalog/components/addpricesheet.tsx","./src/features/catalog/components/pricerow.tsx","./src/features/catalog/components/productformsheet.tsx","./src/features/catalog/components/resolveratepreview.tsx","./src/features/catalog/pages/productpricespage.tsx","./src/features/catalog/pages/productslistpage.tsx","./src/features/customers/routes.tsx","./src/features/customers/components/customerformsheet.tsx","./src/features/customers/components/subscriptionformsheet.tsx","./src/features/customers/hooks/useappnav.ts","./src/features/customers/hooks/usecustomeroutstanding.ts","./src/features/customers/lib/format.ts","./src/features/customers/pages/customerdetailpage.tsx","./src/features/customers/pages/customerslistpage.tsx","./src/features/payments/index.ts","./src/features/payments/routes.tsx","./src/features/payments/components/paymentfilterssheet.tsx","./src/features/payments/components/recordpaymentsheet.tsx","./src/features/payments/hooks/usepaymentsfeature.ts","./src/features/payments/pages/paymentslistpage.tsx","./src/features/route/routes.tsx","./src/features/route/components/recorddeliverysheet.tsx","./src/features/route/components/routesection.tsx","./src/features/route/components/submitconfirmsheet.tsx","./src/features/route/hooks/useonline.ts","./src/features/route/hooks/useroutedraft.ts","./src/features/route/pages/routepage.tsx","./src/features/superadmin/adminspage.tsx","./src/features/superadmin/orgspage.tsx","./src/features/superadmin/index.ts","./src/features/superadmin/routes.tsx","./src/features/superadmin/components/adminrow.tsx","./src/features/superadmin/components/createadminsheet.tsx","./src/features/superadmin/components/orgcard.tsx","./src/features/superadmin/components/orgpickersheet.tsx","./src/features/superadmin/hooks/useorgpicker.ts","./src/features/superadmin/hooks/usesuperadminnav.ts","./src/features/superadmin/lib/seedorg.ts","./src/hooks/usekeyboardaware.ts","./src/hooks/usereducedmotion.ts","./src/lib/env.ts","./src/lib/formerrors.ts","./src/routes/homeplaceholder.tsx","./src/routes/morepage.tsx","./src/routes/preview.tsx","./src/routes/lazypage.tsx","./src/theme/index.ts","./src/theme/motion.ts","./src/theme/palette.ts","./src/theme/theme.d.ts"],"version":"5.9.3"}
|
|
|
|
| 1 |
+
{"root":["./src/approutes.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/apierror.ts","./src/api/client.ts","./src/api/endpoints.ts","./src/api/index.ts","./src/api/keys.ts","./src/api/types.ts","./src/api/hooks/index.ts","./src/api/hooks/useanalytics.ts","./src/api/hooks/useauth.ts","./src/api/hooks/usebills.ts","./src/api/hooks/usecustomerledger.ts","./src/api/hooks/usecustomers.ts","./src/api/hooks/usepayments.ts","./src/api/hooks/useprices.ts","./src/api/hooks/useproducts.ts","./src/api/hooks/useroute.ts","./src/api/hooks/usesettings.ts","./src/api/hooks/usesubscriptions.ts","./src/api/hooks/useusers.ts","./src/app/appshell.tsx","./src/app/bottomnav.tsx","./src/app/fab.tsx","./src/app/pagetransition.tsx","./src/app/stickyactionbar.tsx","./src/app/stickyheader.tsx","./src/app/index.ts","./src/app/navconfig.tsx","./src/app/queryclient.ts","./src/auth/authstore.ts","./src/auth/permissions.ts","./src/components/index.ts","./src/components/buttons/appbutton.tsx","./src/components/buttons/iconaction.tsx","./src/components/charts/barchart.tsx","./src/components/charts/chartframe.tsx","./src/components/charts/donutchart.tsx","./src/components/charts/metriccard.tsx","./src/components/charts/trendchart.tsx","./src/components/domain/billcard.tsx","./src/components/domain/billlinestable.tsx","./src/components/domain/customercard.tsx","./src/components/domain/deliveryrow.tsx","./src/components/domain/ledgerlist.tsx","./src/components/domain/paymentrow.tsx","./src/components/domain/productcard.tsx","./src/components/domain/qrviewer.tsx","./src/components/domain/subscriptionrow.tsx","./src/components/domain/whatsappsharebutton.tsx","./src/components/domain/types.ts","./src/components/feedback/emptystate.tsx","./src/components/feedback/errorboundary.tsx","./src/components/feedback/errorstate.tsx","./src/components/feedback/skeleton.tsx","./src/components/feedback/spinner.tsx","./src/components/feedback/toastprovider.tsx","./src/components/fields/datefield.tsx","./src/components/fields/keyboardawarefield.tsx","./src/components/fields/numberfield.tsx","./src/components/fields/phonefield.tsx","./src/components/fields/qtystepper.tsx","./src/components/fields/searchbar.tsx","./src/components/fields/selectfield.tsx","./src/components/fields/statustoggle.tsx","./src/components/fields/switchfield.tsx","./src/components/fields/textfield.tsx","./src/components/format/datelabel.tsx","./src/components/format/money.tsx","./src/components/format/qty.tsx","./src/components/gating/permissiongate.tsx","./src/components/gating/rolegate.tsx","./src/components/lists/pagedlist.tsx","./src/components/lists/usepulltorefresh.ts","./src/components/nav/datestrip.tsx","./src/components/nav/pagetabs.tsx","./src/components/nav/stickytotalbar.tsx","./src/components/overlays/bottomsheet.tsx","./src/components/overlays/confirmdialog.tsx","./src/components/overlays/modal.tsx","./src/components/status/statuschip.tsx","./src/features/admin/settingspage.tsx","./src/features/admin/staffpage.tsx","./src/features/admin/routes.tsx","./src/features/admin/useadminnav.ts","./src/features/admin/components/createemployeesheet.tsx","./src/features/admin/components/holidayssection.tsx","./src/features/admin/components/permissionlabels.ts","./src/features/admin/components/permissionssheet.tsx","./src/features/admin/components/staffrow.tsx","./src/features/analytics/dashboardpage.tsx","./src/features/analytics/daterangesheet.tsx","./src/features/analytics/rangecontrol.tsx","./src/features/analytics/reportspage.tsx","./src/features/analytics/unitqtychips.tsx","./src/features/analytics/charts.tsx","./src/features/analytics/controls.tsx","./src/features/analytics/index.ts","./src/features/analytics/routes.tsx","./src/features/analytics/unitqty.ts","./src/features/analytics/unittypes.ts","./src/features/analytics/usedaterange.ts","./src/features/auth/routes.tsx","./src/features/auth/pages/loginpage.tsx","./src/features/billing/billdetailpage.tsx","./src/features/billing/billingpage.tsx","./src/features/billing/index.ts","./src/features/billing/routes.tsx","./src/features/billing/components/adjustmentsheet.tsx","./src/features/billing/components/billlistcard.tsx","./src/features/billing/components/billslisttab.tsx","./src/features/billing/components/generatetab.tsx","./src/features/billing/components/recordpaymentsheet.tsx","./src/features/billing/components/statuschangesheet.tsx","./src/features/billing/hooks/usebillingshell.ts","./src/features/billing/hooks/usegeneratebills.ts","./src/features/billing/hooks/usegeneratepreview.ts","./src/features/billing/lib/billlineunit.ts","./src/features/catalog/routes.tsx","./src/features/catalog/unit.ts","./src/features/catalog/usecatalognav.ts","./src/features/catalog/components/addpricesheet.tsx","./src/features/catalog/components/pricerow.tsx","./src/features/catalog/components/productformsheet.tsx","./src/features/catalog/components/resolveratepreview.tsx","./src/features/catalog/pages/productpricespage.tsx","./src/features/catalog/pages/productslistpage.tsx","./src/features/customers/routes.tsx","./src/features/customers/components/consumptionbreakdown.tsx","./src/features/customers/components/customerformsheet.tsx","./src/features/customers/components/subscriptionformsheet.tsx","./src/features/customers/hooks/useappnav.ts","./src/features/customers/hooks/usecustomeroutstanding.ts","./src/features/customers/lib/format.ts","./src/features/customers/lib/units.ts","./src/features/customers/pages/customerdetailpage.tsx","./src/features/customers/pages/customerslistpage.tsx","./src/features/payments/index.ts","./src/features/payments/routes.tsx","./src/features/payments/components/paymentfilterssheet.tsx","./src/features/payments/components/recordpaymentsheet.tsx","./src/features/payments/hooks/usepaymentsfeature.ts","./src/features/payments/pages/paymentslistpage.tsx","./src/features/route/routes.tsx","./src/features/route/components/recorddeliverysheet.tsx","./src/features/route/components/routesection.tsx","./src/features/route/components/submitconfirmsheet.tsx","./src/features/route/hooks/useonline.ts","./src/features/route/hooks/useroutedraft.ts","./src/features/route/pages/routepage.tsx","./src/features/superadmin/adminspage.tsx","./src/features/superadmin/orgspage.tsx","./src/features/superadmin/index.ts","./src/features/superadmin/routes.tsx","./src/features/superadmin/components/adminrow.tsx","./src/features/superadmin/components/createadminsheet.tsx","./src/features/superadmin/components/orgcard.tsx","./src/features/superadmin/components/orgpickersheet.tsx","./src/features/superadmin/hooks/useorgpicker.ts","./src/features/superadmin/hooks/usesuperadminnav.ts","./src/features/superadmin/lib/seedorg.ts","./src/hooks/usekeyboardaware.ts","./src/hooks/usereducedmotion.ts","./src/lib/env.ts","./src/lib/formerrors.ts","./src/lib/unit.ts","./src/routes/homeplaceholder.tsx","./src/routes/morepage.tsx","./src/routes/preview.tsx","./src/routes/lazypage.tsx","./src/theme/index.ts","./src/theme/motion.ts","./src/theme/palette.ts","./src/theme/theme.d.ts"],"version":"5.9.3"}
|