AzureAD\AdityaDevarshi Claude Opus 4.8 (1M context) commited on
Commit
bc92e36
·
1 Parent(s): 7434f35

Units fix (frontend): unit-aware UI + rich analytics

Browse files

Shared 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>

Files changed (37) hide show
  1. frontend/PWA/src/api/types.ts +1 -1
  2. frontend/PWA/src/components/domain/BillLinesTable.tsx +3 -3
  3. frontend/PWA/src/components/domain/types.ts +5 -3
  4. frontend/PWA/src/components/fields/NumberField.tsx +33 -6
  5. frontend/PWA/src/components/fields/QtyStepper.tsx +39 -18
  6. frontend/PWA/src/components/format/Money.tsx +23 -0
  7. frontend/PWA/src/components/format/Qty.tsx +12 -8
  8. frontend/PWA/src/components/index.ts +2 -2
  9. frontend/PWA/src/features/analytics/DashboardPage.tsx +194 -87
  10. frontend/PWA/src/features/analytics/ReportsPage.tsx +103 -44
  11. frontend/PWA/src/features/analytics/UnitQtyChips.tsx +40 -0
  12. frontend/PWA/src/features/analytics/charts.tsx +222 -0
  13. frontend/PWA/src/features/analytics/controls.tsx +119 -0
  14. frontend/PWA/src/features/analytics/unitQty.ts +55 -0
  15. frontend/PWA/src/features/analytics/unitTypes.ts +45 -0
  16. frontend/PWA/src/features/billing/BillDetailPage.tsx +11 -7
  17. frontend/PWA/src/features/billing/components/BillListCard.tsx +63 -0
  18. frontend/PWA/src/features/billing/components/BillsListTab.tsx +6 -3
  19. frontend/PWA/src/features/billing/components/GenerateTab.tsx +3 -2
  20. frontend/PWA/src/features/billing/hooks/useGeneratePreview.ts +30 -6
  21. frontend/PWA/src/features/billing/lib/billLineUnit.ts +44 -0
  22. frontend/PWA/src/features/catalog/components/AddPriceSheet.tsx +26 -3
  23. frontend/PWA/src/features/catalog/components/PriceRow.tsx +8 -5
  24. frontend/PWA/src/features/catalog/components/ResolveRatePreview.tsx +6 -3
  25. frontend/PWA/src/features/catalog/pages/ProductPricesPage.tsx +9 -4
  26. frontend/PWA/src/features/catalog/unit.ts +23 -13
  27. frontend/PWA/src/features/customers/components/ConsumptionBreakdown.tsx +86 -0
  28. frontend/PWA/src/features/customers/components/SubscriptionFormSheet.tsx +19 -5
  29. frontend/PWA/src/features/customers/lib/units.ts +60 -0
  30. frontend/PWA/src/features/customers/pages/CustomerDetailPage.tsx +70 -24
  31. frontend/PWA/src/features/route/components/RecordDeliverySheet.tsx +20 -7
  32. frontend/PWA/src/features/route/components/RouteSection.tsx +4 -5
  33. frontend/PWA/src/features/route/components/SubmitConfirmSheet.tsx +14 -7
  34. frontend/PWA/src/features/route/hooks/useRouteDraft.ts +26 -5
  35. frontend/PWA/src/features/route/pages/RoutePage.tsx +4 -1
  36. frontend/PWA/src/lib/unit.ts +133 -0
  37. frontend/PWA/tsconfig.app.tsbuildinfo +1 -1
frontend/PWA/src/api/types.ts CHANGED
@@ -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;
frontend/PWA/src/components/domain/BillLinesTable.tsx CHANGED
@@ -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 ?? 'L'} component="span" />
81
  </Box>
82
  <Box sx={{ width: 72, textAlign: 'right' }}>
83
- <Money value={l.rate} component="span" />
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" />
frontend/PWA/src/components/domain/types.ts CHANGED
@@ -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?: 'L' | 'kg' | 'u';
16
  active: boolean;
17
  }
18
 
@@ -24,7 +26,7 @@ export interface DeliveryRowData {
24
  productName: string;
25
  status: DeliveryStatus;
26
  qty: number;
27
- unit?: 'L' | 'kg' | 'u';
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?: 'L' | 'kg' | 'u';
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
  }
frontend/PWA/src/components/fields/NumberField.tsx CHANGED
@@ -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 — decimal numeric (qty / amount / rate).
25
- // inputMode="decimal" (milk is fractional 0.5/1.5 L); ₹/L adornment.
 
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="decimal"
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: 'decimal', pattern: '[0-9]*[.]?[0-9]*' },
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">
frontend/PWA/src/components/fields/QtyStepper.tsx CHANGED
@@ -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?: string;
 
12
  disabled?: boolean;
13
  }
14
 
15
- const round1 = (n: number) => Math.round(n * 10) / 10;
16
-
17
- // DESIGN_SYSTEM §8.2 −/value/+ with inline numeric. step 0.5 (fractional milk),
18
- // 44px tap targets. Tapping −/+ commits the value (no lingering keyboard).
19
  export function QtyStepper({
20
  value,
21
  onChange,
22
- step = 0.5,
23
- min = 0,
24
  max,
25
- unit = 'L',
26
  disabled = false,
27
  }: QtyStepperProps) {
 
 
 
 
28
  const clamp = (n: number) => {
29
  let v = n;
30
- if (min !== undefined) v = Math.max(min, v);
31
  if (max !== undefined) v = Math.min(max, v);
32
- return round1(v);
33
  };
34
 
35
- const dec = () => onChange(clamp(value - step));
36
- const inc = () => onChange(clamp(value + step));
37
 
38
  return (
39
  <Box
@@ -53,18 +68,24 @@ export function QtyStepper({
53
  <InputBase
54
  value={value}
55
  onChange={(e) => {
56
- const v = e.target.value;
57
- onChange(v === '' ? min : clamp(Number(v)));
 
 
 
 
 
 
58
  }}
59
  inputProps={{
60
- inputMode: 'decimal',
61
- pattern: '[0-9]*[.]?[0-9]*',
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" />
frontend/PWA/src/components/format/Money.tsx CHANGED
@@ -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
+ }
frontend/PWA/src/components/format/Qty.tsx CHANGED
@@ -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?: QtyUnit;
 
 
 
8
  }
9
 
10
- const unitLabel: Record<QtyUnit, string> = { L: 'L', kg: 'kg', u: '' };
11
-
12
- // DESIGN_SYSTEM §8.4 unit-aware quantity. Milk is fractional (0.5 / 1.5 L).
13
- export function Qty({ value, unit = 'L', sx, ...rest }: QtyProps) {
14
- // Up to one decimal; trim trailing .0
15
- const num = Number.isInteger(value) ? String(value) : value.toFixed(1);
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/pktnever 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"
frontend/PWA/src/components/index.ts CHANGED
@@ -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';
frontend/PWA/src/features/analytics/DashboardPage.tsx CHANGED
@@ -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 WaterDrop from '@mui/icons-material/WaterDrop';
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. Read-only (no action bar). Per-region
40
- // independent loading/empty/error; count-up metrics; ApexCharts (animations auto-off
41
- // under reduced-motion inside each chart). Gated by analytics.view at the route.
 
 
 
 
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
- const [trendMetric, setTrendMetric] = useState<'amount' | 'qty'>('amount');
 
 
 
 
 
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 per A.1.
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: trendMetric === 'amount' ? p.amount : p.qty,
91
  })),
92
- [trend.data, trendMetric],
93
  );
 
 
94
 
 
95
  const donutData = useMemo(
96
- () => (byProduct.data ?? []).map((p) => ({ label: p.product_name, value: p.amount })),
97
- [byProduct.data],
98
  );
99
  const donutTotal = donutData.reduce((acc, d) => acc + d.value, 0);
100
 
101
- const agingData = useMemo(
102
- () => (aging.data?.buckets ?? []).map((b) => ({ label: b.bucket, value: b.amount })),
 
 
 
 
 
 
 
 
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
- {/* SALES TREND */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  <Card sx={{ p: 2 }}>
168
  <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
169
- <Typography variant="h3">Sales trend</Typography>
170
- <Stack direction="row" spacing={1} alignItems="center">
171
- <TrendingUp fontSize="small" sx={{ color: 'text.secondary' }} />
172
- <AppButton
173
- variant={trendMetric === 'amount' ? 'secondary' : 'ghost'}
174
- size="sm"
175
- onPress={() => setTrendMetric('amount')}
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
- <TrendChart
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  series={trendSeries}
190
- bucket={bucket}
191
- metric={trendMetric}
 
192
  loading={trend.isLoading}
193
  error={trend.isError}
194
  onRetry={() => trend.refetch()}
195
- height={180}
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
- <WaterDrop fontSize="small" sx={{ color: 'text.secondary' }} />
 
 
204
  </Stack>
205
  <DonutChart
206
  data={donutData}
207
- centerLabel="Total"
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
- {topRows.map((c, i) => (
244
- <CustomerCard
245
- key={c.customer_id ?? i}
246
- rank={i + 1}
247
- customer={{
248
- id: String(c.customer_id ?? i),
249
- name: c.customer_name ?? 'Unknown',
250
- active: true,
251
- }}
252
- metrics={
253
- <>
254
- <Money value={c.amount} component="span" /> · {c.avg_daily_qty.toFixed(1)} L/day
255
- </>
256
- }
257
- onTap={() => c.customer_id != null && navigate(`/customers/${c.customer_id}`)}
258
- />
259
- ))}
260
- {(top.data?.length ?? 0) > topShown ? (
261
- <AppButton variant="ghost" fullWidth onPress={() => setTopShown((n) => n + TOP_LIMIT)}>
262
- Load more
263
- </AppButton>
264
- ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
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
- <BarChart data={agingData} colorRamp={agingRamp} metric="amount" height={200} />
 
 
 
 
 
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
- <Typography variant="body2">{b.bucket}</Typography>
 
 
 
 
 
 
 
 
 
 
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>
frontend/PWA/src/features/analytics/ReportsPage.tsx CHANGED
@@ -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
- // design-dashboard.md / §9.2 Reports — same analytics data, TABULAR. Read-only; PageTabs
49
- // switch views; shared DateRangeSheet. Gated by reports.view (+ analytics.view) at route.
 
 
 
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 s = summary.data;
 
 
 
 
 
 
 
 
 
 
 
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 label="Total quantity" value={`${(s?.total_qty ?? 0).toFixed(1)} L`} />
 
 
 
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
- <Typography variant="overline" color="text.secondary">
128
- Sales by product
129
- </Typography>
 
 
 
 
 
130
  {byProduct.isLoading ? (
131
  <Skeleton variant="row" count={5} />
132
  ) : byProduct.isError ? (
@@ -139,30 +173,41 @@ export function ReportsPage() {
139
  </AppButton>
140
  }
141
  />
142
- ) : (byProduct.data?.length ?? 0) === 0 ? (
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">Amount</TableCell>
151
  <TableCell align="right">%</TableCell>
152
  </TableRow>
153
  </TableHead>
154
  <TableBody>
155
- {(byProduct.data ?? []).map((p) => (
156
  <TableRow key={p.product_id}>
157
  <TableCell>{p.product_name}</TableCell>
158
- <TableCell align="right" sx={num}>{p.qty.toFixed(1)}</TableCell>
159
- <TableCell align="right"><Money value={p.amount} /></TableCell>
160
- <TableCell align="right" sx={num}>{p.pct_of_revenue.toFixed(0)}%</TableCell>
 
 
 
 
 
 
 
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
- ) : (top.data?.length ?? 0) === 0 ? (
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">Amount</TableCell>
195
- <TableCell align="right">Avg/day</TableCell>
196
  </TableRow>
197
  </TableHead>
198
  <TableBody>
199
- {(top.data ?? []).map((c, i) => (
200
- <TableRow
201
- key={c.customer_id ?? i}
202
- hover
203
- sx={{ cursor: c.customer_id != null ? 'pointer' : 'default' }}
204
- onClick={() => c.customer_id != null && navigate(`/customers/${c.customer_id}`)}
205
- >
206
- <TableCell sx={num}>{i + 1}</TableCell>
207
- <TableCell>{c.customer_name ?? 'Unknown'}</TableCell>
208
- <TableCell align="right"><Money value={c.amount} /></TableCell>
209
- <TableCell align="right" sx={num}>{c.avg_daily_qty.toFixed(1)} L</TableCell>
210
- </TableRow>
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"><Money value={b.amount} /></TableCell>
252
- <TableCell align="right" sx={num}>{b.count}</TableCell>
 
 
 
 
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>
frontend/PWA/src/features/analytics/UnitQtyChips.tsx ADDED
@@ -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
+ }
frontend/PWA/src/features/analytics/charts.tsx ADDED
@@ -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
+ }
frontend/PWA/src/features/analytics/controls.tsx ADDED
@@ -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
+ }
frontend/PWA/src/features/analytics/unitQty.ts ADDED
@@ -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
+ }
frontend/PWA/src/features/analytics/unitTypes.ts ADDED
@@ -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) ?? [];
frontend/PWA/src/features/billing/BillDetailPage.tsx CHANGED
@@ -47,15 +47,16 @@ import {
47
  useSettings,
48
  usePayments,
49
  } from '@/api';
50
- import type { BillAdjustment, ProductUnit } from '@/api/types';
 
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: 'L' | 'kg' | 'u' }>();
92
- (productsQ.data?.items ?? []).forEach((p) => m.set(p.id, { name: p.name, unit: UNIT_MAP[p.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 ?? 'Item',
105
- unit: meta?.unit ?? 'L',
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,
frontend/PWA/src/features/billing/components/BillListCard.tsx ADDED
@@ -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
+ }
frontend/PWA/src/features/billing/components/BillsListTab.tsx CHANGED
@@ -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 { BillCard, PagedList, SearchBar, AppButton } from '@/components';
 
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
- <BillCard
 
 
 
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}
frontend/PWA/src/features/billing/components/GenerateTab.tsx CHANGED
@@ -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={<Qty value={ctl.preview.totalQty} />}
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
frontend/PWA/src/features/billing/hooks/useGeneratePreview.ts CHANGED
@@ -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
- totalQty: number;
 
 
 
 
 
 
 
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 { totalQty, estAmount } = useMemo(() => {
50
- const rows = byProductQ.data ?? [];
 
 
 
 
 
 
 
 
51
  return {
52
- totalQty: rows.reduce((s, r) => s + (r.qty ?? 0), 0),
53
- estAmount: rows.reduce((s, r) => s + (r.amount ?? 0), 0),
 
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
- totalQty,
 
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: () => {
frontend/PWA/src/features/billing/lib/billLineUnit.ts ADDED
@@ -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
+ }
frontend/PWA/src/features/catalog/components/AddPriceSheet.tsx CHANGED
@@ -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({ open, onClose, productId, customerOptions }: AddPriceSheetProps) {
 
 
 
 
 
 
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 name="rate" control={control} label="Rate" money min={0} step={0.5} required />
 
 
 
 
 
 
 
 
 
 
 
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
frontend/PWA/src/features/catalog/components/PriceRow.tsx CHANGED
@@ -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 { Money, DateLabel, IconAction } from '@/components';
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
- export function PriceRow({ price, customerName, onDelete }: PriceRowProps) {
 
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
- <Money value={price.rate} />
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} />
frontend/PWA/src/features/catalog/components/ResolveRatePreview.tsx CHANGED
@@ -4,14 +4,17 @@ import Sell from '@mui/icons-material/Sell';
4
  import {
5
  DateField,
6
  SelectField,
7
- Money,
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
- <Money value={data.rate} />
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">
frontend/PWA/src/features/catalog/pages/ProductPricesPage.tsx CHANGED
@@ -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 { unitToken } from '../unit';
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
- Unit <Qty value={1} unit={unitToken(product.unit)} />
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 productId={productId} customerOptions={customerOptions} />
 
 
 
 
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>
frontend/PWA/src/features/catalog/unit.ts CHANGED
@@ -1,21 +1,31 @@
1
  import type { ProductUnit } from '@/api/types';
 
2
 
3
- // Map the backend ProductUnit ('litre'|'kg'|'piece') to the short unit token the
4
- // presentational library (ProductCard / Qty / NumberField adornment) expects.
5
- export function unitToken(unit: ProductUnit | undefined): 'L' | 'kg' | 'u' {
6
- switch (unit) {
7
- case 'kg':
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 (u)' },
 
 
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
+ }
frontend/PWA/src/features/customers/components/ConsumptionBreakdown.tsx ADDED
@@ -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
+ }
frontend/PWA/src/features/customers/components/SubscriptionFormSheet.tsx CHANGED
@@ -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={0.5}
201
- unit="L"
 
202
  selectAllOnFocus
203
  />
204
  </Box>
@@ -208,8 +221,9 @@ export function SubscriptionFormSheet({
208
  control={control}
209
  label="Evening qty"
210
  min={0}
211
- step={0.5}
212
- unit="L"
 
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>
frontend/PWA/src/features/customers/lib/units.ts ADDED
@@ -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
+ }
frontend/PWA/src/features/customers/pages/CustomerDetailPage.tsx CHANGED
@@ -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-mix bars, computed outstanding,
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
- const productBars = useMemo(
127
- () =>
128
- (analytics?.by_product ?? []).map((p) => ({ label: p.product_name, value: p.amount })),
129
- [analytics],
130
- );
 
 
 
131
 
132
  const openAddSub = () => {
133
  setEditingSub(null);
@@ -245,7 +270,9 @@ export function CustomerDetailPage() {
245
  }
246
  />
247
 
248
- {/* CONSUMPTION HEADLINE — 3 mini MetricCards (qty / spend / avg per day) */}
 
 
249
  <Box
250
  sx={{
251
  display: 'grid',
@@ -254,21 +281,22 @@ export function CustomerDetailPage() {
254
  }}
255
  >
256
  <MetricCard
257
- icon={WaterDrop}
258
- label="Qty"
259
  loading={analyticsQuery.isLoading}
260
  error={analyticsQuery.isError}
261
  onRetry={() => analyticsQuery.refetch()}
262
- value={<Qty value={analytics?.total_qty ?? 0} unit="L" />}
263
  note={analytics ? `${analytics.days_served} days` : undefined}
264
  />
265
  <MetricCard
266
- icon={Payments}
267
- label="Spend"
268
  loading={analyticsQuery.isLoading}
269
  error={analyticsQuery.isError}
270
  onRetry={() => analyticsQuery.refetch()}
271
- value={<Money value={analytics?.total_amount ?? 0} countUp />}
 
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={<Qty value={analytics?.avg_daily_qty ?? 0} unit="L" />}
280
- note={analytics ? `${analytics.days_served} served` : undefined}
 
 
 
 
 
 
 
 
281
  />
282
  </Box>
283
 
284
- {/* PRODUCT MIXhorizontal bars (true time-series is a backend GAP, see B.1) */}
 
 
285
  <Box>
286
  <SectionHeader icon={WaterDrop} title="What they buy" />
287
  {analyticsQuery.isLoading ? (
288
- <Skeleton variant="chart" />
289
- ) : productBars.length === 0 ? (
 
 
 
 
 
 
290
  <EmptyState variant="card" icon={WaterDrop} title="No deliveries yet" />
291
  ) : (
292
- <BarChart data={productBars} metric="amount" />
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={analytics?.by_product?.find((p) => p.product_id === s.product_id)?.product_name ?? `Product #${s.product_id}`}
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
frontend/PWA/src/features/route/components/RecordDeliverySheet.tsx CHANGED
@@ -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' ? round1(Number(qty) || 0) * previewRate : 0;
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
- actual_qty: status === 'delivered' ? round1(Number(values.qty) || 0) : 0,
 
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} L
239
  </Typography>
240
  ) : null}
241
  </Stack>
242
  <QtyStepper
243
  value={Number(qty) || 0}
244
  onChange={(q) => setValue('qty', q)}
245
- unit="L"
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}/L` : 'Rate resolved on save'}
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 />
frontend/PWA/src/features/route/components/RouteSection.tsx CHANGED
@@ -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: 'L',
82
  rate: row.rate,
83
  amount:
84
- row.status === 'delivered' ? round1(row.qty) * row.rate : undefined,
85
- extra: row.adHoc ? round1(row.qty) : undefined,
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}
frontend/PWA/src/features/route/components/SubmitConfirmSheet.tsx CHANGED
@@ -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
- deliveredQty += round1(r.qty);
41
- billable += round1(r.qty) * r.rate;
 
42
  } else if (r.status === 'skipped') skipped += 1;
43
  else if (r.status === 'hold') hold += 1;
44
  }
45
- return { deliveredCount, deliveredQty: round1(deliveredQty), skipped, hold, extras, billable };
 
 
 
 
 
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.deliveredQty} L`} />
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
frontend/PWA/src/features/route/hooks/useRouteDraft.ts CHANGED
@@ -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
- const setQty = useCallback((key: string, qty: number) => patchRow(key, { qty: round1(qty) }), [patchRow]);
 
 
 
 
 
 
 
 
 
 
 
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 + round1(r.qty) * r.rate : 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],
frontend/PWA/src/features/route/pages/RoutePage.tsx CHANGED
@@ -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
- actual_qty: r.status === 'delivered' ? r.qty : 0,
 
 
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
  }));
frontend/PWA/src/lib/unit.ts ADDED
@@ -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
+ }
frontend/PWA/tsconfig.app.tsbuildinfo CHANGED
@@ -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"}