Add units of measure and fractional quantities to rate lines
Quantities can now be time, count, distance, area, weight or volume, or a custom unit, instead of only
second/minute/hour/session/unit.
- Registry in src/lib/units.ts (mirrored in src-tauri/src/units.rs): grouped built-in units plus a custom unit of 1-12
characters. A rate line's unit is validated when an invoice or a preset is saved; migration M12 drops the old unit
allow-list from item_presets and keeps every row.
- Time is typed as h:mm: "4:30" hours counts as 4.5, "4:20" as 4.333... (kept exact, so rate x quantity rounds once, the
same in TypeScript and Rust). Other units take plain decimals up to 3 places. A bad quantity blocks issuing.
- The PDF quantity column shows the unit ("4.5 hr", "12.75 km"); the rate keeps "per hour" / "/hr". RenderItem carries
priceText, perText and rateShort, so no template parses the rate text with a regex over five hard-coded units.
- Quantity columns are sized from the widest word once a quantity with its unit is wider than 72 pt, so one long
custom unit cannot squeeze the description (the serenity and citrus-split tables overflowed in the new fixture).
Classic's quantity column is now content-sized.
- ERPNext: the default UOM map comes from the registry, with fractional-capable UOMs for time, distance, area, weight
and volume. A custom unit is sent as Nos. A quantity that is not exact to 3 decimals (4:20 hours) is still refused for
push, as before.
- Goldens and template thumbnails regenerated; the template harness has a "units" fixture with the longest texts.
Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
type FormProblems,
|
||||
} from "../lib/erpnextUi";
|
||||
import { useToast } from "./ToastProvider";
|
||||
import { unitLabel } from "../lib/units";
|
||||
|
||||
const SEVERITY_TITLE = { error: "Needs fixing", warning: "Warning", info: "Note" } as const;
|
||||
|
||||
@@ -496,12 +497,12 @@ export default function ErpnextSettingsPanel({ active }: { active: boolean }) {
|
||||
/>
|
||||
<fieldset className="voiced-erp__uoms">
|
||||
<legend>Units of measure</legend>
|
||||
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities.</p>
|
||||
<p className="voiced-muted">The ERPNext unit used for each Voiced unit. Whole-number units such as Nos reject fractional quantities. A custom unit typed on an invoice is sent as Nos. Month is not a stock ERPNext unit: create it there or pick another.</p>
|
||||
{UOM_UNITS.map((unit) => (
|
||||
<PickField
|
||||
key={unit}
|
||||
id={`erp-uom-${unit}`}
|
||||
label={`Voiced ${unit}`}
|
||||
label={`Voiced ${unitLabel(unit).toLowerCase()}`}
|
||||
value={form.uomMap[unit] ?? ""}
|
||||
onChange={(v) => setForm((f) => (f ? { ...f, uomMap: { ...f.uomMap, [unit]: v } } : f))}
|
||||
items={opts.uoms}
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
} from "@carbon/react";
|
||||
import { Add, Edit, TrashCan } from "@carbon/icons-react";
|
||||
import { api } from "../lib/api";
|
||||
import { UNIT_LABELS, blankPreset, type ItemPreset, type LineMode, type LineUnit } from "../lib/types";
|
||||
import { blankPreset, type ItemPreset, type LineMode } from "../lib/types";
|
||||
import { isValidUnit, ratePerText } from "../lib/units";
|
||||
import UnitPicker from "./UnitPicker";
|
||||
import { paiseToRupees, rupeesToPaise } from "../lib/presets";
|
||||
import { formatAmount } from "../lib/format";
|
||||
import { useToast } from "./ToastProvider";
|
||||
@@ -61,9 +63,12 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
||||
if (!editing) return;
|
||||
setTouched(true);
|
||||
if (!editing.description.trim()) return;
|
||||
if (editing.mode === "rate" && !isValidUnit(editing.unit)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.saveItemPreset({ ...editing, description: editing.description.trim() });
|
||||
// A fixed-amount preset has no unit of its own; whatever the picker last held must not block the save.
|
||||
const unit = isValidUnit(editing.unit) ? editing.unit : "unit";
|
||||
await api.saveItemPreset({ ...editing, unit, description: editing.description.trim() });
|
||||
toast.success(editing.id === null ? "Preset added" : "Preset updated", editing.description.trim());
|
||||
setEditing(null);
|
||||
await load();
|
||||
@@ -123,7 +128,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
||||
<TableRow key={p.id ?? p.description}>
|
||||
<TableCell>{p.description}</TableCell>
|
||||
<TableCell>{p.hsnSac || "—"}</TableCell>
|
||||
<TableCell>{p.mode === "rate" ? UNIT_LABELS[p.unit] : "Fixed amount"}</TableCell>
|
||||
<TableCell>{p.mode === "rate" ? ratePerText(p.unit) : "Fixed amount"}</TableCell>
|
||||
<TableCell>₹{formatAmount(paiseToRupees(p.ratePaise))}</TableCell>
|
||||
<TableCell>
|
||||
<div className="voiced-header-action">
|
||||
@@ -199,16 +204,7 @@ export default function ItemPresetsPanel({ active }: { active: boolean }) {
|
||||
</Column>
|
||||
{editing.mode === "rate" ? (
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
<Select
|
||||
id="ip-unit"
|
||||
labelText="Unit"
|
||||
value={editing.unit}
|
||||
onChange={(e) => set("unit", e.target.value as LineUnit)}
|
||||
>
|
||||
{Object.entries(UNIT_LABELS).map(([value, text]) => (
|
||||
<SelectItem key={value} value={value} text={text} />
|
||||
))}
|
||||
</Select>
|
||||
<UnitPicker id="ip-unit" value={editing.unit} onChange={(unit) => set("unit", unit)} />
|
||||
</Column>
|
||||
) : null}
|
||||
<Column sm={4} md={4} lg={8}>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TextInput } from "@carbon/react";
|
||||
import { formatQuantity, knownUnit, parseQuantity } from "../lib/units";
|
||||
|
||||
export interface QuantityInputProps {
|
||||
id: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
onChange: (quantity: number) => void;
|
||||
disabled?: boolean;
|
||||
/** Reports whether the text is a quantity, so the form can refuse to issue with a bad one. */
|
||||
onValidity?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantity of a rate line. Takes decimals for any unit, and for time units also `4:30` (4 h 30 min),
|
||||
* showing the decimal it counts as. The text is the user's own while they type.
|
||||
*/
|
||||
export default function QuantityInput({ id, value, unit, onChange, disabled, onValidity }: QuantityInputProps) {
|
||||
const [text, setText] = useState(() => formatQuantity(value, unit));
|
||||
// What the box last reported, so a change that came from outside (a preset, a draft) can tell itself apart.
|
||||
const reported = useRef({ value, unit });
|
||||
|
||||
useEffect(() => {
|
||||
if (reported.current.value === value && reported.current.unit === unit) return;
|
||||
reported.current = { value, unit };
|
||||
const next = formatQuantity(value, unit);
|
||||
setText(next);
|
||||
// What is stored must be what the box says: a time kept as 4:20 has no exact decimal form in another unit.
|
||||
const again = parseQuantity(next, unit);
|
||||
if (again.ok && Math.abs(again.value - value) > 1e-9) {
|
||||
reported.current = { value: again.value, unit };
|
||||
onChange(again.value);
|
||||
}
|
||||
// onChange is the parent's setter; only a new value or unit should re-sync the text.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, unit]);
|
||||
|
||||
const parsed = parseQuantity(text, unit);
|
||||
const clock = knownUnit(unit)?.clock;
|
||||
const validity = useRef(onValidity);
|
||||
validity.current = onValidity;
|
||||
useEffect(() => validity.current?.(parsed.ok), [parsed.ok]);
|
||||
|
||||
const helper = parsed.ok && text.includes(":") ? `= ${formatQuantity(parsed.value, "unit")} ${knownUnit(unit)?.short ?? ""}`.trim() : undefined;
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
labelText="Quantity"
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
placeholder={clock === "hours" ? "e.g. 4:30 or 4.5" : undefined}
|
||||
value={text}
|
||||
invalid={!parsed.ok}
|
||||
invalidText={parsed.ok ? "" : parsed.error}
|
||||
helperText={helper}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setText(next);
|
||||
const r = parseQuantity(next, unit);
|
||||
if (r.ok) {
|
||||
reported.current = { value: r.value, unit };
|
||||
onChange(r.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Select, SelectItem, SelectItemGroup, TextInput } from "@carbon/react";
|
||||
import { MAX_CUSTOM_UNIT_LENGTH, UNITS, UNIT_GROUPS, isKnownUnit, validateCustomUnit } from "../lib/units";
|
||||
|
||||
const OTHER = "__other__";
|
||||
|
||||
export interface UnitPickerProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (unit: string) => void;
|
||||
disabled?: boolean;
|
||||
labelText?: string;
|
||||
}
|
||||
|
||||
/** The unit of a rate line: a grouped list of built-in units plus "Other…", which takes free text. */
|
||||
export default function UnitPicker({ id, value, onChange, disabled, labelText = "Unit" }: UnitPickerProps) {
|
||||
// "Other…" can be chosen before any text is typed, so the mode is kept apart from the stored value.
|
||||
const [other, setOther] = useState(() => !isKnownUnit(value));
|
||||
const custom = other || !isKnownUnit(value);
|
||||
const problem = custom ? validateCustomUnit(value) : null;
|
||||
|
||||
// A unit that arrives from outside (another preset or draft in the same row) decides the mode again; one this picker
|
||||
// just reported does not, so typing "m" on the way to "minute" does not flip the box back to the list.
|
||||
const reported = useRef(value);
|
||||
useEffect(() => {
|
||||
if (value === reported.current) return;
|
||||
reported.current = value;
|
||||
setOther(!isKnownUnit(value));
|
||||
}, [value]);
|
||||
const emit = (unit: string) => {
|
||||
reported.current = unit;
|
||||
onChange(unit);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
labelText={labelText}
|
||||
value={custom ? OTHER : value}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === OTHER) {
|
||||
setOther(true);
|
||||
emit("");
|
||||
} else {
|
||||
setOther(false);
|
||||
emit(e.target.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{UNIT_GROUPS.map((group) => (
|
||||
<SelectItemGroup key={group} label={group}>
|
||||
{UNITS.filter((u) => u.group === group).map((u) => (
|
||||
<SelectItem key={u.id} value={u.id} text={u.label} />
|
||||
))}
|
||||
</SelectItemGroup>
|
||||
))}
|
||||
<SelectItem value={OTHER} text="Other…" />
|
||||
</Select>
|
||||
{custom ? (
|
||||
<TextInput
|
||||
id={`${id}-custom`}
|
||||
disabled={disabled}
|
||||
labelText="Custom unit"
|
||||
hideLabel
|
||||
placeholder="e.g. bundle"
|
||||
maxLength={MAX_CUSTOM_UNIT_LENGTH}
|
||||
value={value}
|
||||
invalid={Boolean(problem)}
|
||||
invalidText={problem ?? ""}
|
||||
onChange={(e) => emit(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -23,7 +23,7 @@ export interface ErpnextConfig {
|
||||
paymentBankAccount: string;
|
||||
tdsAccount: string;
|
||||
defaultItemCode: string;
|
||||
/** Voiced unit (second, minute, hour, session, unit) to ERPNext UOM. */
|
||||
/** Voiced unit id (see lib/units.ts) to ERPNext UOM. */
|
||||
uomMap: Record<string, string>;
|
||||
customerGroup: string;
|
||||
territory: string;
|
||||
|
||||
@@ -232,7 +232,7 @@ describe("settings form", () => {
|
||||
expect(input).not.toHaveProperty("apiSecretSet");
|
||||
expect(input).not.toHaveProperty("lastDetectResult");
|
||||
expect(input.apiSecret).toBe("");
|
||||
expect(input.uomMap).toEqual({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos" });
|
||||
expect(input.uomMap).toMatchObject({ second: "Second", minute: "Minute", hour: "Hr", session: "Nos", unit: "Nos", km: "Kilometer", sqft: "Square Foot" });
|
||||
expect(withDefaultUoms({ hour: " " }).hour).toBe("Hour");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Pure helpers behind the ERPNext screens (Settings, History, invoice detail, auto-push). No UI, no Tauri. */
|
||||
import { UNITS } from "./units";
|
||||
import type {
|
||||
ErpnextConfig,
|
||||
ErpnextConfigInput,
|
||||
@@ -259,14 +260,8 @@ export function versionsLine(test: ErpnextConnectionTest): string {
|
||||
|
||||
// ---- settings form ----
|
||||
|
||||
export const UOM_UNITS = ["second", "minute", "hour", "session", "unit"] as const;
|
||||
export const DEFAULT_UOM_MAP: Record<string, string> = {
|
||||
second: "Second",
|
||||
minute: "Minute",
|
||||
hour: "Hour",
|
||||
session: "Nos",
|
||||
unit: "Nos",
|
||||
};
|
||||
export const UOM_UNITS: readonly string[] = UNITS.map((u) => u.id);
|
||||
export const DEFAULT_UOM_MAP: Record<string, string> = Object.fromEntries(UNITS.map((u) => [u.id, u.uom]));
|
||||
|
||||
export function withDefaultUoms(map: Record<string, string> | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = { ...DEFAULT_UOM_MAP };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeLineAmount, computeTotals } from "./invoice";
|
||||
import { EMPTY_ITEM, type InvoiceItem } from "./types";
|
||||
import { parseQuantity } from "./units";
|
||||
|
||||
// These cases mirror the unit tests in src-tauri/src/gst.rs, so the on-screen preview
|
||||
// and the stored totals stay identical.
|
||||
@@ -40,6 +41,19 @@ describe("computeTotals", () => {
|
||||
expect(computeLineAmount(rate(99.99, 2.5))).toBe(249.98);
|
||||
});
|
||||
|
||||
it("counts a typed time as its decimal and rounds once, like Rust (units test: lines_can_use_any_unit...)", () => {
|
||||
const q = (text: string, unit: string) => {
|
||||
const r = parseQuantity(text, unit);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.value;
|
||||
};
|
||||
expect(computeLineAmount(rate(1200, q("4:30", "hour")))).toBe(5400);
|
||||
// 4 h 20 min is 4.333...; 1200 x that is 5200.00 only if the exact value is kept, not 4.33.
|
||||
expect(computeLineAmount(rate(1200, q("4:20", "hour")))).toBe(5200);
|
||||
expect(computeLineAmount({ ...rate(18, q("12.75", "km")), unit: "km" })).toBe(229.5);
|
||||
expect(computeLineAmount(rate(99.5, 3))).toBe(298.5);
|
||||
});
|
||||
|
||||
it("clamps the discount to the subtotal", () => {
|
||||
const items = [fixed(100), fixed(50.5)];
|
||||
const over = computeTotals(items, 999.99, "igst", 18);
|
||||
|
||||
+2
-8
@@ -1,6 +1,7 @@
|
||||
export type TaxType = "none" | "cgst_sgst" | "igst";
|
||||
export type LineMode = "fixed" | "rate";
|
||||
export type LineUnit = "second" | "minute" | "hour" | "session" | "unit";
|
||||
/** A built-in unit id (see lib/units.ts) or, for a custom unit, the text the vendor typed. */
|
||||
export type LineUnit = string;
|
||||
|
||||
export interface Settings {
|
||||
vendorName: string;
|
||||
@@ -302,10 +303,3 @@ export const INDIAN_STATES: { code: string; name: string }[] = [
|
||||
{ code: "97", name: "Other Territory" },
|
||||
];
|
||||
|
||||
export const UNIT_LABELS: Record<LineUnit, string> = {
|
||||
second: "per second",
|
||||
minute: "per minute",
|
||||
hour: "per hour",
|
||||
session: "per session",
|
||||
unit: "per unit",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
UNITS,
|
||||
formatQuantity,
|
||||
normalizeUnit,
|
||||
parseQuantity,
|
||||
quantityText,
|
||||
ratePerText,
|
||||
unitShort,
|
||||
validateCustomUnit,
|
||||
} from "./units";
|
||||
|
||||
const value = (text: string, unit: string) => {
|
||||
const r = parseQuantity(text, unit);
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.value;
|
||||
};
|
||||
|
||||
describe("parseQuantity", () => {
|
||||
it("reads h:mm as hours for hour units", () => {
|
||||
expect(value("4:30", "hour")).toBe(4.5);
|
||||
expect(value("0:15", "hour")).toBe(0.25);
|
||||
expect(value("4:20", "hour")).toBeCloseTo(4 + 1 / 3, 12);
|
||||
expect(value("1:00:30", "hour")).toBeCloseTo(1 + 30 / 3600, 12);
|
||||
});
|
||||
|
||||
it("reads m:ss as minutes for minute units", () => {
|
||||
expect(value("4:30", "minute")).toBe(4.5);
|
||||
expect(parseQuantity("1:00:30", "minute").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a clock quantity for other units and out-of-range parts", () => {
|
||||
expect(parseQuantity("4:30", "km").ok).toBe(false);
|
||||
expect(parseQuantity("4:75", "hour").ok).toBe(false);
|
||||
expect(parseQuantity("4:", "hour").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("reads plain decimals for every unit", () => {
|
||||
expect(value("12.75", "km")).toBe(12.75);
|
||||
expect(value("120.5", "sqft")).toBe(120.5);
|
||||
expect(value("3", "unit")).toBe(3);
|
||||
expect(value(".5", "hour")).toBe(0.5);
|
||||
expect(value("2.", "kg")).toBe(2);
|
||||
expect(value("", "hour")).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects text, negatives, more than 3 decimals and huge values", () => {
|
||||
for (const bad of ["abc", "-1", "1,5", "1.2345", ".", "1e3", "99999999"]) {
|
||||
expect(parseQuantity(bad, "hour").ok, bad).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatQuantity", () => {
|
||||
it("trims zeros", () => {
|
||||
expect(formatQuantity(2, "unit")).toBe("2");
|
||||
expect(formatQuantity(1.5, "km")).toBe("1.5");
|
||||
expect(formatQuantity(12.75, "km")).toBe("12.75");
|
||||
expect(formatQuantity(0.125, "kg")).toBe("0.125");
|
||||
});
|
||||
|
||||
it("writes a repeating time as h:mm", () => {
|
||||
expect(formatQuantity(value("4:20", "hour"), "hour")).toBe("4:20");
|
||||
expect(formatQuantity(value("0:20", "hour"), "hour")).toBe("0:20");
|
||||
expect(formatQuantity(value("1:00:30", "hour"), "hour")).toBe("1:00:30");
|
||||
expect(formatQuantity(value("4:30", "hour"), "hour")).toBe("4.5");
|
||||
});
|
||||
|
||||
it("round-trips whatever parseQuantity accepts", () => {
|
||||
for (const [text, unit] of [["4:20", "hour"], ["7:45", "hour"], ["12.75", "km"], ["0:05", "hour"], ["3:10", "minute"]] as const) {
|
||||
const v = value(text, unit);
|
||||
expect(value(formatQuantity(v, unit), unit)).toBeCloseTo(v, 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("unit text", () => {
|
||||
it("keeps the old rate wording for the original five units", () => {
|
||||
expect(["second", "minute", "hour", "session", "unit"].map(ratePerText)).toEqual([
|
||||
"per second",
|
||||
"per minute",
|
||||
"per hour",
|
||||
"per session",
|
||||
"per unit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the unit beside the quantity, but not for the generic unit", () => {
|
||||
expect(quantityText(4.5, "hour")).toBe("4.5 hr");
|
||||
expect(quantityText(12.75, "km")).toBe("12.75 km");
|
||||
expect(quantityText(3, "unit")).toBe("3");
|
||||
expect(quantityText(2, "bundle")).toBe("2 bundle");
|
||||
expect(unitShort("sqft")).toBe("sq ft");
|
||||
expect(ratePerText("bundle")).toBe("per bundle");
|
||||
});
|
||||
|
||||
it("validates custom units", () => {
|
||||
expect(validateCustomUnit("bundle")).toBeNull();
|
||||
expect(validateCustomUnit("sq.ft/day")).toBeNull();
|
||||
expect(validateCustomUnit("")).not.toBeNull();
|
||||
expect(validateCustomUnit("a".repeat(13))).not.toBeNull();
|
||||
expect(validateCustomUnit("₹")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("normalises stored values", () => {
|
||||
expect(normalizeUnit("HOUR")).toBe("hour");
|
||||
expect(normalizeUnit(" bundle ")).toBe("bundle");
|
||||
expect(normalizeUnit("")).toBe("unit");
|
||||
expect(normalizeUnit(undefined)).toBe("unit");
|
||||
});
|
||||
|
||||
it("has unique ids and a short for every unit except the generic one", () => {
|
||||
expect(new Set(UNITS.map((u) => u.id)).size).toBe(UNITS.length);
|
||||
for (const u of UNITS) if (u.id !== "unit") expect(u.short).not.toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Units of measure for rate × quantity lines. The ids of the first five are stored in saved invoices
|
||||
* and must never change. Any other text is a custom unit and is stored as typed (see `validateCustomUnit`).
|
||||
* The Rust side keeps the same rules in src-tauri/src/units.rs.
|
||||
*/
|
||||
|
||||
export type UnitGroup = "Count" | "Time" | "Distance" | "Area" | "Weight" | "Volume";
|
||||
|
||||
/** How `h:mm` style quantities are read for a unit: "4:30" is 4 h 30 min for hours, 4 min 30 s for minutes. */
|
||||
export type ClockKind = "hours" | "minutes";
|
||||
|
||||
export interface UnitDef {
|
||||
id: string;
|
||||
/** Shown in pickers: "Hour". */
|
||||
label: string;
|
||||
/** Lower-case word used after "per": "per hour". */
|
||||
noun: string;
|
||||
/** Short form beside a quantity: "4.5 hr". Empty for the generic "unit", which stays a bare number. */
|
||||
short: string;
|
||||
group: UnitGroup;
|
||||
clock?: ClockKind;
|
||||
/** ERPNext UOM used when the integration has no mapping of its own for this unit. */
|
||||
uom: string;
|
||||
}
|
||||
|
||||
export const UNITS: readonly UnitDef[] = [
|
||||
{ id: "unit", label: "Unit", noun: "unit", short: "", group: "Count", uom: "Nos" },
|
||||
{ id: "piece", label: "Piece", noun: "piece", short: "pc", group: "Count", uom: "Nos" },
|
||||
{ id: "set", label: "Set", noun: "set", short: "set", group: "Count", uom: "Set" },
|
||||
{ id: "session", label: "Session", noun: "session", short: "session", group: "Time", uom: "Nos" },
|
||||
{ id: "second", label: "Second", noun: "second", short: "sec", group: "Time", uom: "Second" },
|
||||
{ id: "minute", label: "Minute", noun: "minute", short: "min", group: "Time", clock: "minutes", uom: "Minute" },
|
||||
{ id: "hour", label: "Hour", noun: "hour", short: "hr", group: "Time", clock: "hours", uom: "Hour" },
|
||||
{ id: "day", label: "Day", noun: "day", short: "day", group: "Time", uom: "Day" },
|
||||
{ id: "week", label: "Week", noun: "week", short: "wk", group: "Time", uom: "Week" },
|
||||
{ id: "month", label: "Month", noun: "month", short: "mo", group: "Time", uom: "Month" },
|
||||
{ id: "km", label: "Kilometre", noun: "km", short: "km", group: "Distance", uom: "Kilometer" },
|
||||
{ id: "m", label: "Metre", noun: "m", short: "m", group: "Distance", uom: "Meter" },
|
||||
{ id: "sqft", label: "Square foot", noun: "sq ft", short: "sq ft", group: "Area", uom: "Square Foot" },
|
||||
{ id: "sqm", label: "Square metre", noun: "sq m", short: "sq m", group: "Area", uom: "Square Meter" },
|
||||
{ id: "kg", label: "Kilogram", noun: "kg", short: "kg", group: "Weight", uom: "Kg" },
|
||||
{ id: "litre", label: "Litre", noun: "litre", short: "L", group: "Volume", uom: "Litre" },
|
||||
];
|
||||
|
||||
export const UNIT_GROUPS: readonly UnitGroup[] = ["Count", "Time", "Distance", "Area", "Weight", "Volume"];
|
||||
|
||||
const BY_ID = new Map(UNITS.map((u) => [u.id, u]));
|
||||
|
||||
export const DEFAULT_UNIT = "unit";
|
||||
export const MAX_CUSTOM_UNIT_LENGTH = 12;
|
||||
|
||||
/** The built-in unit with this id, or undefined for a custom unit. */
|
||||
export const knownUnit = (unit: string): UnitDef | undefined => BY_ID.get(unit.trim().toLowerCase());
|
||||
|
||||
export const isKnownUnit = (unit: string): boolean => BY_ID.has(unit.trim().toLowerCase());
|
||||
|
||||
/** Problem with a custom unit text, or null when it is fine. */
|
||||
export function validateCustomUnit(text: string): string | null {
|
||||
const t = text.trim();
|
||||
if (!t) return "Enter a unit";
|
||||
if (t.length > MAX_CUSTOM_UNIT_LENGTH) return `Use at most ${MAX_CUSTOM_UNIT_LENGTH} characters`;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9 ./-]*$/.test(t)) return "Use letters, digits, spaces, . / or -";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a stored unit can be issued: a built-in id or a valid custom text. */
|
||||
export const isValidUnit = (unit: string): boolean => isKnownUnit(unit) || validateCustomUnit(unit) === null;
|
||||
|
||||
/** A unit value as stored: the id for a built-in unit, the trimmed text otherwise. Falls back to the default. */
|
||||
export function normalizeUnit(unit: string | null | undefined): string {
|
||||
const t = (unit ?? "").trim();
|
||||
if (!t) return DEFAULT_UNIT;
|
||||
const lower = t.toLowerCase();
|
||||
if (BY_ID.has(lower)) return lower;
|
||||
return t;
|
||||
}
|
||||
|
||||
export const unitLabel = (unit: string): string => knownUnit(unit)?.label ?? unit.trim();
|
||||
|
||||
/** "hr" for hours; the text itself for a custom unit; "" for the generic unit. */
|
||||
export const unitShort = (unit: string): string => {
|
||||
const def = knownUnit(unit);
|
||||
return def ? def.short : unit.trim();
|
||||
};
|
||||
|
||||
/** "per hour", "per sq ft", "per bundle". */
|
||||
export const ratePerText = (unit: string): string => `per ${knownUnit(unit)?.noun ?? unit.trim()}`;
|
||||
|
||||
/** The ERPNext UOM that suits a built-in unit; undefined for a custom unit. */
|
||||
export const defaultUom = (unit: string): string | undefined => knownUnit(unit)?.uom;
|
||||
|
||||
export type QuantityParse = { ok: true; value: number } | { ok: false; error: string };
|
||||
|
||||
const MAX_QUANTITY = 10_000_000;
|
||||
|
||||
/**
|
||||
* Reads what the user typed into a quantity box. Plain decimals (up to 3 places) work for every unit.
|
||||
* Hour units also take `h:mm` and `h:mm:ss`, minute units take `m:ss`, so "4:30" hours is 4.5.
|
||||
* An empty box is 0, the same as before this existed.
|
||||
*/
|
||||
export function parseQuantity(text: string, unit: string): QuantityParse {
|
||||
const t = text.trim();
|
||||
if (!t) return { ok: true, value: 0 };
|
||||
const clock = knownUnit(unit)?.clock;
|
||||
|
||||
if (t.includes(":")) {
|
||||
if (!clock) return { ok: false, error: "Only time units take h:mm" };
|
||||
const m = /^(\d{1,7}):(\d{1,2})(?::(\d{1,2}))?$/.exec(t);
|
||||
if (!m) return { ok: false, error: clock === "hours" ? "Use h:mm, for example 4:30" : "Use m:ss, for example 4:30" };
|
||||
const first = Number(m[1]);
|
||||
const second = Number(m[2]);
|
||||
const third = m[3] === undefined ? null : Number(m[3]);
|
||||
if (second > 59 || (third !== null && third > 59)) return { ok: false, error: "Minutes and seconds go up to 59" };
|
||||
if (clock === "minutes" && third !== null) return { ok: false, error: "Use m:ss, for example 4:30" };
|
||||
const value = clock === "hours" ? first + second / 60 + (third ?? 0) / 3600 : first + second / 60;
|
||||
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
|
||||
}
|
||||
|
||||
const m = /^(\d{0,8})(?:\.(\d{0,3}))?$/.exec(t);
|
||||
if (!m || (m[1] === "" && !m[2])) {
|
||||
return { ok: false, error: /^\d*\.\d{4,}$/.test(t) ? "Use at most 3 decimal places" : "Enter a number" };
|
||||
}
|
||||
const value = Number(t);
|
||||
return value > MAX_QUANTITY ? { ok: false, error: "That quantity is too large" } : { ok: true, value };
|
||||
}
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
function trimmed(value: number, places: number): string {
|
||||
return value.toFixed(places).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A quantity as text, without the unit. Exact for anything `parseQuantity` accepts: a time that is not a
|
||||
* whole number of hundredths (4 h 20 min) is written as h:mm so that quantity × rate can be checked by eye.
|
||||
*/
|
||||
export function formatQuantity(value: number, unit: string): string {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
const clock = knownUnit(unit)?.clock;
|
||||
if (!clock) return trimmed(value, 3);
|
||||
if (Math.abs(value * 100 - Math.round(value * 100)) < EPS * 100) return trimmed(value, 2);
|
||||
const whole = Math.floor(value + EPS);
|
||||
const rest = (value - whole) * 60;
|
||||
const min = Math.floor(rest + EPS);
|
||||
const sec = Math.round((rest - min) * 60);
|
||||
const head = clock === "hours" ? `${whole}:${String(min).padStart(2, "0")}` : null;
|
||||
if (clock === "hours" && sec < 60 && Math.abs(whole + min / 60 + sec / 3600 - value) < EPS) {
|
||||
return sec === 0 ? head! : `${head}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
if (clock === "minutes") {
|
||||
const s = Math.round((value - whole) * 60);
|
||||
if (s < 60 && Math.abs(whole + s / 60 - value) < EPS) return `${whole}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
return trimmed(value, 4);
|
||||
}
|
||||
|
||||
/** The quantity as printed on an invoice: "4:20 hr", "12.75 km", or a bare "3" for the generic unit. */
|
||||
export function quantityText(value: number, unit: string): string {
|
||||
const q = formatQuantity(value, unit);
|
||||
const short = unitShort(unit);
|
||||
return short ? `${q} ${short}` : q;
|
||||
}
|
||||
@@ -181,6 +181,23 @@ export function getMeasurer(): Measurer {
|
||||
/** The text as it will be drawn: caps applied. */
|
||||
export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text);
|
||||
|
||||
/** A quantity with its unit ("1250.5 sq ft") is allowed this much width before the cell wraps at its spaces. */
|
||||
export const QTY_WRAP_CAP = 72;
|
||||
|
||||
/**
|
||||
* The texts a quantity column is sized from. A quantity cell can wrap between its words ("120 / wooden crate")
|
||||
* but never inside one, so a text wider than `cap` counts as its widest word; the column then stays modest
|
||||
* and the description keeps its room, instead of one long custom unit squeezing the table.
|
||||
*/
|
||||
export function wrapAwareTexts(texts: string[], role: RoleName | TypeToken, cap = QTY_WRAP_CAP): string[] {
|
||||
const m = getMeasurer();
|
||||
return texts.map((text) => {
|
||||
if (measureToken(m, role, text) <= cap) return text;
|
||||
const words = text.split(/\s+/).filter(Boolean);
|
||||
return words.reduce((widest, w) => (measureToken(m, role, w) > measureToken(m, role, widest) ? w : widest), words[0] ?? text);
|
||||
});
|
||||
}
|
||||
|
||||
/** Width of `text` set in a role/token at an optional size override, tracking and caps included. */
|
||||
export function measureToken(
|
||||
m: Measurer,
|
||||
|
||||
@@ -133,7 +133,10 @@ describe("unregistered invoice", () => {
|
||||
descriptionLines: ["Voice-over", "session A"],
|
||||
hsnSac: "998397",
|
||||
rateText: "₹1,200.00 per hour",
|
||||
qtyText: "2.5",
|
||||
priceText: "₹1,200.00",
|
||||
perText: "per hour",
|
||||
rateShort: "₹1,200.00/hr",
|
||||
qtyText: "2.5 hr",
|
||||
amountText: "₹3,000.00",
|
||||
});
|
||||
expect(m.items[1]).toEqual({
|
||||
@@ -141,10 +144,22 @@ describe("unregistered invoice", () => {
|
||||
descriptionLines: ["—"],
|
||||
hsnSac: null,
|
||||
rateText: null,
|
||||
priceText: null,
|
||||
perText: null,
|
||||
rateShort: null,
|
||||
qtyText: "1",
|
||||
amountText: "₹2,500.50",
|
||||
});
|
||||
});
|
||||
it("words the unit and the quantity for every kind of unit", () => {
|
||||
const line = (unit: string, quantity: number, rate = 18.5) =>
|
||||
make({ items: [{ ...base.items[0], mode: "rate", unit, quantity, rate }] }).items[0];
|
||||
expect(line("km", 12.75)).toMatchObject({ qtyText: "12.75 km", rateText: "₹18.50 per km", rateShort: "₹18.50/km", perText: "per km" });
|
||||
expect(line("sqft", 1250.5)).toMatchObject({ qtyText: "1250.5 sq ft", rateShort: "₹18.50/sq ft" });
|
||||
expect(line("hour", 4 + 1 / 3)).toMatchObject({ qtyText: "4:20 hr" });
|
||||
expect(line("unit", 3)).toMatchObject({ qtyText: "3", rateText: "₹18.50 per unit", rateShort: "₹18.50/unit" });
|
||||
expect(line(" bundle ", 2)).toMatchObject({ qtyText: "2 bundle", rateText: "₹18.50 per bundle", rateShort: "₹18.50/bundle" });
|
||||
});
|
||||
it("labels the bank lines and defaults the signature to the vendor name", () => {
|
||||
expect(m.bank).toEqual({
|
||||
accountName: "Test Vendor",
|
||||
|
||||
+20
-3
@@ -1,7 +1,7 @@
|
||||
import { computeLineAmount } from "../../lib/invoice";
|
||||
import { quantityText, ratePerText, unitShort, knownUnit } from "../../lib/units";
|
||||
import {
|
||||
INDIAN_STATES,
|
||||
UNIT_LABELS,
|
||||
type BankAccount,
|
||||
type Invoice,
|
||||
type Settings,
|
||||
@@ -48,7 +48,15 @@ export interface RenderItem {
|
||||
sno: number;
|
||||
descriptionLines: string[];
|
||||
hsnSac: string | null;
|
||||
/** "₹1,200.00 per hour"; null for a fixed-amount line. */
|
||||
rateText: string | null;
|
||||
/** The amount alone: "₹1,200.00". */
|
||||
priceText: string | null;
|
||||
/** The unit wording alone: "per hour". */
|
||||
perText: string | null;
|
||||
/** The one-line form: "₹1,200.00/hr". */
|
||||
rateShort: string | null;
|
||||
/** "4.5 hr", "12.75 km", or a bare "3" for the generic unit; "1" for a fixed-amount line. */
|
||||
qtyText: string;
|
||||
amountText: string;
|
||||
}
|
||||
@@ -140,6 +148,15 @@ function taxRowsFor(invoice: Invoice, supplierStateCode: string): TaxRow[] {
|
||||
}
|
||||
|
||||
/** Pure: turns the stored invoice into pre-formatted, sanitised data. Totals are the stored values. */
|
||||
function rateFields(item: Invoice["items"][number]): Pick<RenderItem, "rateText" | "priceText" | "perText" | "rateShort"> {
|
||||
if (item.mode !== "rate") return { rateText: null, priceText: null, perText: null, rateShort: null };
|
||||
const priceText = formatRupees(item.rate);
|
||||
const perText = ratePerText(item.unit);
|
||||
// The generic "unit" has no short form beside a quantity, but its rate still reads "/unit".
|
||||
const suffix = unitShort(item.unit) || knownUnit(item.unit)?.noun || item.unit.trim();
|
||||
return { rateText: `${priceText} ${perText}`, priceText, perText, rateShort: `${priceText}/${suffix}` };
|
||||
}
|
||||
|
||||
export function buildRenderModel(input: BuildInput): RenderModel {
|
||||
const { invoice, logo, signatureDataUri, appVersion } = input;
|
||||
const vendor = vendorFromSnapshot(invoice.vendorSnapshot, input.vendor);
|
||||
@@ -164,8 +181,8 @@ export function buildRenderModel(input: BuildInput): RenderModel {
|
||||
return lines.length ? lines : ["—"];
|
||||
})(),
|
||||
hsnSac: orNull(item.hsnSac),
|
||||
rateText: item.mode === "rate" ? `${formatRupees(item.rate)} ${UNIT_LABELS[item.unit]}` : null,
|
||||
qtyText: item.mode === "rate" ? String(item.quantity) : "1",
|
||||
...rateFields(item),
|
||||
qtyText: item.mode === "rate" ? quantityText(item.quantity, item.unit) : "1",
|
||||
amountText: formatRupees(computeLineAmount(item)),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
||||
import { textHeight } from "../../engine/textplan";
|
||||
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
|
||||
@@ -150,17 +150,17 @@ export function planBand(model: RenderModel, frame: PageFrame, v: BandVariant, l
|
||||
// --- table: description | qty | price | total ------------------------------------------------------------
|
||||
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
|
||||
const colW = (base: number, texts: string[], headText: string) => Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, [headText]))) + 4 + 1);
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
|
||||
const cw = {
|
||||
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "QTY"),
|
||||
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "PRICE"),
|
||||
qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"),
|
||||
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "PRICE"),
|
||||
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "TOTAL"),
|
||||
};
|
||||
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.qty }, { fixed: cw.rate }, { fixed: cw.amount }];
|
||||
const headerCells = ["SERVICE", "QTY", "PRICE", "TOTAL"];
|
||||
const align: BandPlan["align"] = ["left", "center", "right", "right"];
|
||||
const rows: TableCellValue[][] = model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, cw.rate - 4, r.tableBody);
|
||||
const rate = rateCell(item, cw.rate - 4, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [desc, item.qtyText, rateParts, item.amountText];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cols, type ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer } from "../../engine/measure";
|
||||
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
||||
import { textHeight } from "../../engine/textplan";
|
||||
import { roleToken } from "../../fonts/roles";
|
||||
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
@@ -82,6 +82,9 @@ export interface ClassicPlan {
|
||||
closingH: number;
|
||||
}
|
||||
|
||||
const QTY_MIN = 40;
|
||||
const QTY_MAX = 84;
|
||||
|
||||
const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leading as number;
|
||||
|
||||
/**
|
||||
@@ -92,12 +95,22 @@ const leading = (role: Parameters<typeof roleToken>[0]) => roleToken(role).leadi
|
||||
export function planClassic(model: RenderModel, frame: PageFrame): ClassicPlan {
|
||||
const gaps = elasticGaps(frame);
|
||||
const hsn = Slot.HsnSac(model);
|
||||
// The quantity column grows to hold "12.75 km" on one line, but the description keeps its 160 pt: a text wider than
|
||||
// that room wraps at its spaces.
|
||||
const fixedBesideQty = 28 + (hsn ? 62 : 0) + 92 + 88 + TABLE_GAP * (hsn ? 5 : 4);
|
||||
const qtyRoom = Math.max(QTY_MIN, Math.min(QTY_MAX, Math.floor(frame.contentWidth - fixedBesideQty - 160)));
|
||||
const qtyNeed = Math.ceil(
|
||||
Math.max(
|
||||
measureToken(getMeasurer(), "tableHead", "Qty"),
|
||||
...wrapAwareTexts(model.items.map((i) => i.qtyText), "tableBody", qtyRoom).map((t) => measureToken(getMeasurer(), "tableBody", t)),
|
||||
),
|
||||
);
|
||||
const columns: ColSpec[] = [
|
||||
{ fixed: 28 },
|
||||
{ fill: true, min: 160 },
|
||||
...(hsn ? [{ fixed: 62 } as ColSpec] : []),
|
||||
{ fixed: 92 },
|
||||
{ fixed: 40 },
|
||||
{ fixed: Math.min(qtyRoom, Math.max(QTY_MIN, qtyNeed + 2)) },
|
||||
{ fixed: 88 },
|
||||
];
|
||||
const header = ["S.No", "Description", ...(hsn ? [hsn.header] : []), "Unit price", "Qty", "Amount"];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
||||
import { textHeight } from "../../engine/textplan";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
import { innerRulePadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
@@ -19,7 +19,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
|
||||
const MIN_ADDRESS_W = 120;
|
||||
/** Space between a mark and the tagline under a wordmark logo. */
|
||||
const WORDMARK_TAG_GAP = 3;
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
|
||||
|
||||
/** The totals' labels read "Sub Total :", "CGST 9% :" like the design's "Tax 15% :". */
|
||||
export const labelWithColon = (label: string, rate?: string) => (rate ? `${label} ${rate} :` : `${label} :`);
|
||||
@@ -65,7 +65,7 @@ export function tableRows(model: RenderModel, v: CobaltVariant, rateW: number):
|
||||
const r = v.roles;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, rateW, r.tableBody);
|
||||
const rate = rateCell(item, rateW, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [String(i + 1), desc, item.qtyText, rateParts, item.amountText];
|
||||
@@ -245,10 +245,10 @@ export function planCobalt(model: RenderModel, frame: PageFrame, v: CobaltVarian
|
||||
const t = v.table;
|
||||
const rule = t.separator;
|
||||
const widest = (role: TypeToken, texts: string[]) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, role, x))));
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
const priced = model.items.map((i) => priceOf(i));
|
||||
const amountTexts = model.items.map((i) => i.amountText);
|
||||
const no = Math.max(t.cols.no, widest(r.tableBody, [String(model.items.length)]) + 12);
|
||||
const qty = Math.max(t.cols.qty, widest(r.tableBody, model.items.map((i) => i.qtyText)) + 12 + rule);
|
||||
const qty = Math.max(t.cols.qty, widest(r.tableBody, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody)) + 12 + rule);
|
||||
const priceNeed = Math.max(widest(r.tableBody, priced), widest(r.tableHead, ["Price"])) + 6 + rule + 6;
|
||||
const price = Math.max(t.cols.price, priceNeed);
|
||||
const priceInset = Math.max(6, Math.min(t.priceInset, price - rule - widest(r.tableBody, priced) - 4));
|
||||
|
||||
@@ -297,6 +297,19 @@ export const FIXTURES: FixtureSpec[] = [
|
||||
{ description: "Audio editing and mastering of all deliverables, including noise reduction, levelling, and export to the three formats requested by the client in the brief of August", mode: "rate", rate: 400, unit: "minute", quantity: 25, amount: 0, sortOrder: 2, hsnSac: "998397" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Units of measure: a decimal distance, an area, a time that is not a whole number of hundredths (4 h 20 min),
|
||||
// a 12-character custom unit and a month, so the quantity and rate columns see the longest texts they can.
|
||||
name: "units",
|
||||
items: [
|
||||
{ description: "Travel to the client's studio", mode: "rate", rate: 18.5, unit: "km", quantity: 12.75, amount: 0, sortOrder: 0, hsnSac: "996601" },
|
||||
{ description: "Acoustic treatment, floor area", mode: "rate", rate: 85, unit: "sqft", quantity: 1250.5, amount: 0, sortOrder: 1, hsnSac: "995411" },
|
||||
{ description: "Evening studio session", mode: "rate", rate: 1500, unit: "hour", quantity: 4 + 1 / 3, amount: 0, sortOrder: 2, hsnSac: "998397" },
|
||||
{ description: "Packed equipment cases", mode: "rate", rate: 99.5, unit: "wooden crate", quantity: 120, amount: 0, sortOrder: 3, hsnSac: "998397" },
|
||||
{ description: "Retainer", mode: "rate", rate: 25000, unit: "month", quantity: 1, amount: 0, sortOrder: 4, hsnSac: "998397" },
|
||||
{ description: "Setup fee", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 8000, sortOrder: 5, hsnSac: "997212" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "long-notes",
|
||||
items: 30,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { drawnText, getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { drawnText, getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
||||
import { textHeight, textLines } from "../../engine/textplan";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
@@ -37,7 +37,7 @@ export function tableRows(model: RenderModel, v: HighlighterVariant, rateW: numb
|
||||
const r = v.roles;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, rateW, r.tableBody);
|
||||
const rate = rateCell(item, rateW, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [desc, item.qtyText, rateParts, item.amountText];
|
||||
@@ -158,14 +158,14 @@ export function planHighlighter(model: RenderModel, frame: PageFrame, v: Highlig
|
||||
// --- table -----------------------------------------------------------------------------------------------------------------
|
||||
const t = v.table;
|
||||
const innerW = W - 2 * fr;
|
||||
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
|
||||
const rateOnly = model.items.map((i) => (i.priceText ?? "—"));
|
||||
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + v.hair + 6;
|
||||
const shortRate = model.items.map((i) => {
|
||||
const cell = rateCell(i.rateText, 10000, r.tableBody);
|
||||
const cell = rateCell(i, 10000, r.tableBody);
|
||||
return typeof cell === "string" ? cell : "—";
|
||||
});
|
||||
const cw = {
|
||||
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)),
|
||||
qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0)),
|
||||
rate: Math.max(t.cols.rate, need(rateOnly, "Price", t.numPad), Math.min(t.cols.rate + 24, need(shortRate, "Price", t.numPad))),
|
||||
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad) - v.hair),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { colsDetailed, type ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
||||
import { textHeight, textLines } from "../../engine/textplan";
|
||||
import { gridPadRight, planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
|
||||
@@ -140,13 +140,13 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
|
||||
const widest = (role: TypeToken, texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
|
||||
const colW = (base: number, texts: string[], headText: string, idx: number) =>
|
||||
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + ruleCols(idx) + 1);
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
|
||||
const nCols = hsnCol ? 5 : 4;
|
||||
const idx = (base: number) => (hsnCol ? base + 1 : base);
|
||||
const c0 = {
|
||||
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnCol.header, 1) : 0,
|
||||
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit Price", idx(1)),
|
||||
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty", idx(2)),
|
||||
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit Price", idx(1)),
|
||||
qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", idx(2)),
|
||||
amount: colW(v.cols.amount, model.items.map((i) => i.amountText), "Amount", idx(3)),
|
||||
};
|
||||
const columns: ColSpec[] = [{ fill: true }, ...(hsnCol ? [{ fixed: c0.hsn } as ColSpec] : []), { fixed: c0.rate }, { fixed: c0.qty }, { fixed: c0.amount }];
|
||||
@@ -154,7 +154,7 @@ export function planLedger(model: RenderModel, frame: PageFrame, v: LedgerVarian
|
||||
const align: LedgerPlan["align"] = ["left", ...(hsnCol ? (["left"] as const) : []), "right", "right", "right"];
|
||||
const rateInner = c0.rate - 2 * v.padX - ruleCols(idx(1));
|
||||
const tableRows: TableCellValue[][] = model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, rateInner, r.tableBody);
|
||||
const rate = rateCell(item, rateInner, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
return [item.descriptionLines, ...(hsnCol ? [hsnCol.values[i]] : []), rateParts, item.qtyText, item.amountText];
|
||||
});
|
||||
|
||||
@@ -282,7 +282,7 @@ describe("table", () => {
|
||||
expect(r0.h).toBeCloseTo(32, 0);
|
||||
expect(r1.y - r0.y).toBeCloseTo(32, 0);
|
||||
expect(r0.y - (head.y + head.h)).toBeCloseTo(V.table.headGap, 0);
|
||||
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
|
||||
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!;
|
||||
expect((qty.x + qty.right) / 2).toBeCloseTo(355.5, 0);
|
||||
expect(r0.x).toBeCloseTo(31.5, 0);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
||||
import { textHeight } from "../../engine/textplan";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
@@ -62,7 +62,7 @@ export function tableRows(model: RenderModel, v: MarbleVariant, rateW: number):
|
||||
const r = v.roles;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, rateW, r.tableBody);
|
||||
const rate = rateCell(item, rateW, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [desc, item.qtyText, rateParts, item.amountText];
|
||||
@@ -165,14 +165,14 @@ export function planMarble(model: RenderModel, frame: PageFrame, v: MarbleVarian
|
||||
const t = v.table;
|
||||
const need = (texts: string[], head: string, pad: number, headStep: number) =>
|
||||
Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head) - headStep)) + pad + 6;
|
||||
const rateOnly = model.items.map((i) => (i.rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(i.rateText)?.[1] ?? i.rateText) : "—"));
|
||||
const rateOnly = model.items.map((i) => (i.priceText ?? "—"));
|
||||
// The rate shows as "₹1,200.00/hr" when the column can take it (up to RATE_MAX wide), else as the rate over "per hour".
|
||||
const shortRate = model.items.map((i) => {
|
||||
const cell = rateCell(i.rateText, 10000, r.tableBody);
|
||||
const cell = rateCell(i, 10000, r.tableBody);
|
||||
return typeof cell === "string" ? cell : "—";
|
||||
});
|
||||
const cw = {
|
||||
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0, 0)),
|
||||
qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0, 0)),
|
||||
rate: Math.max(t.cols.rate, need(rateOnly, "Rate", t.ratePad, 0), Math.min(RATE_MAX, need(shortRate, "Rate", t.ratePad, 0))),
|
||||
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad, 0)),
|
||||
};
|
||||
|
||||
@@ -193,7 +193,7 @@ describe("table, totals and footer", () => {
|
||||
expect(amount.text, `row ${i}`).toMatch(/^₹[\d,]+\.\d\d$/);
|
||||
expect(amount.right, `row ${i} amount edge`).toBeCloseTo(right - V.table.numPad, 0);
|
||||
}
|
||||
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1")!;
|
||||
const qty = textLines(byId(r, `${TABLE_ROW_PREFIX}0`)).find((l) => l.text === "1 hr")!;
|
||||
const plan = planMonolith(r.fixture.model, frameForPrefs(r.prefs), V, null, false);
|
||||
const left = frameForPrefs(r.prefs).pad.left + plan.W - plan.cw.amount - plan.cw.qty;
|
||||
expect(qty.x + (qty.right - qty.x) / 2).toBeCloseTo(left + plan.cw.qty / 2, 0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
||||
import { textHeight } from "../../engine/textplan";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
import { planItemsTable, type CellPart, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
@@ -18,7 +18,7 @@ const GSTIN_SAMPLE = "GSTIN: 29AAAAA0000A1Z5";
|
||||
export const REVERSE_GAP = 4;
|
||||
/** Space between the label of a words / notes block and its text. */
|
||||
export const LABEL_GAP = 4;
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
|
||||
|
||||
/** "Tax" rows read "CGST @ 9%" like the design's "Tax". */
|
||||
export const atRate = (label: string, rate: string | undefined) => (rate ? `${label} @ ${rate}` : label);
|
||||
@@ -58,7 +58,7 @@ export function tableRows(model: RenderModel, v: MonolithVariant, priceW: number
|
||||
const r = v.roles;
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, priceW, r.tableBody);
|
||||
const rate = rateCell(item, priceW, r.tableBody);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: r.tableSub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.tableSub }] : item.descriptionLines;
|
||||
return [desc, rateParts, item.qtyText, item.amountText];
|
||||
@@ -165,11 +165,11 @@ export function planMonolith(model: RenderModel, frame: PageFrame, v: MonolithVa
|
||||
|
||||
// --- table ---------------------------------------------------------------------------------------------------------
|
||||
const t = v.table;
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
const priced = model.items.map((i) => priceOf(i));
|
||||
const need = (texts: string[], head: string, pad: number) => Math.ceil(Math.max(0, ...texts.map((x) => measureToken(m, r.tableBody, x)), measureToken(m, r.tableHead, head))) + pad + 6;
|
||||
const cw = {
|
||||
price: Math.max(t.cols.price, need(priced, "Price", t.numPad)),
|
||||
qty: Math.max(t.cols.qty, need(model.items.map((i) => i.qtyText), "Qty", 0)),
|
||||
qty: Math.max(t.cols.qty, need(wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty", 0)),
|
||||
amount: Math.max(t.cols.amount, need(model.items.map((i) => i.amountText), "Total", t.numPad)),
|
||||
};
|
||||
const columns: ColSpec[] = [{ fill: true }, { fixed: cw.price }, { fixed: cw.qty }, { fixed: cw.amount }];
|
||||
|
||||
@@ -131,9 +131,12 @@ describe("helpers", () => {
|
||||
|
||||
it("rateCell keeps '₹1,200.00/hr' on one line when it fits and splits the unit otherwise", () => {
|
||||
const body = linea.roles.tableBody;
|
||||
expect(rateCell("₹1,200.00 per hour", 90, body)).toBe("₹1,200.00/hr");
|
||||
expect(rateCell("₹1,200.00 per session", 60, body)).toEqual(["₹1,200.00", "per session"]);
|
||||
expect(rateCell(null, 90, body)).toBe("—");
|
||||
const rate = (price: string, per: string, short: string) => ({ priceText: price, perText: per, rateShort: `${price}/${short}` });
|
||||
expect(rateCell(rate("₹1,200.00", "per hour", "hr"), 90, body)).toBe("₹1,200.00/hr");
|
||||
expect(rateCell(rate("₹1,200.00", "per session", "session"), 60, body)).toEqual(["₹1,200.00", "per session"]);
|
||||
expect(rateCell(rate("₹18.50", "per sq ft", "sq ft"), 200, body)).toBe("₹18.50/sq ft");
|
||||
expect(rateCell(rate("₹18.50", "per bundle", "bundle"), 20, body)).toEqual(["₹18.50", "per bundle"]);
|
||||
expect(rateCell({ priceText: null, perText: null, rateShort: null }, 90, body)).toBe("—");
|
||||
expect(getMeasurer()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { cols, type ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
||||
import { textHeight, textLines } from "../../engine/textplan";
|
||||
import { planItemsTable, type ItemsTablePlan, type TableCellValue } from "../../blocks/ItemsTable";
|
||||
import { signatoryHeight, Slot, slotHeights, type SignatoryOptions } from "../../blocks/slots";
|
||||
import { guardPresence } from "../../blocks/TableGuard";
|
||||
import type { RenderModel } from "../../model/build";
|
||||
import type { RenderItem, RenderModel } from "../../model/build";
|
||||
import type { PlacedLogo } from "../contract";
|
||||
import type { GapRange, NeutralVariant } from "./variant";
|
||||
|
||||
@@ -103,18 +103,17 @@ export function metaRows(model: RenderModel): MetaRowSpec[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const SHORT_UNIT: Record<string, string> = { second: "/sec", minute: "/min", hour: "/hr", session: "/session", unit: "/unit" };
|
||||
|
||||
/**
|
||||
* The rate cell: "₹1,200.00/hr" on one line when it fits the column, else the amount with its unit
|
||||
* ("per hour") on a second line, which keeps one-line rows one line tall.
|
||||
*/
|
||||
export function rateCell(rateText: string | null, width: number, role: NeutralVariant["roles"]["tableBody"]): TableCellValue {
|
||||
if (!rateText) return "—";
|
||||
const m = /^(.*) per (second|minute|hour|session|unit)$/.exec(rateText);
|
||||
if (!m) return rateText;
|
||||
const short = `${m[1]}${SHORT_UNIT[m[2]]}`;
|
||||
return measureToken(getMeasurer(), role, short) <= width ? short : [m[1], `per ${m[2]}`];
|
||||
export function rateCell(
|
||||
item: Pick<RenderItem, "priceText" | "perText" | "rateShort">,
|
||||
width: number,
|
||||
role: NeutralVariant["roles"]["tableBody"],
|
||||
): TableCellValue {
|
||||
if (!item.priceText || !item.perText || !item.rateShort) return "—";
|
||||
return measureToken(getMeasurer(), role, item.rateShort) <= width ? item.rateShort : [item.priceText, item.perText];
|
||||
}
|
||||
|
||||
export interface NeutralPlan {
|
||||
@@ -224,12 +223,12 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
|
||||
const widest = (role: NeutralVariant["roles"]["tableBody"], texts: string[]) => Math.max(0, ...texts.map((t) => measureToken(m, role, t)));
|
||||
const colW = (base: number, texts: string[], headText: string) =>
|
||||
Math.max(base, Math.ceil(Math.max(widest(r.tableBody, texts), widest(r.tableHead, headText.split(" ")))) + 2 * v.padX + 1);
|
||||
const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
|
||||
const hsnHead = hsnCol?.header ?? "";
|
||||
const c = {
|
||||
hsn: hsnCol ? colW(v.cols.hsn, hsnCol.values, hsnHead) : 0,
|
||||
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i.rateText)), "Unit price"),
|
||||
qty: colW(v.cols.qty, model.items.map((i) => i.qtyText), "Qty"),
|
||||
rate: colW(v.cols.rate, model.items.map((i) => priceOf(i)), "Unit price"),
|
||||
qty: colW(v.cols.qty, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "Qty"),
|
||||
amount: colW(v.cols.amount, [...model.items.map((i) => i.amountText), "Total"], "Total"),
|
||||
};
|
||||
const columns: ColSpec[] = [
|
||||
@@ -246,7 +245,7 @@ export function planNeutral(model: RenderModel, frame: PageFrame, v: NeutralVari
|
||||
const tableRows: TableCellValue[][] = model.items.map((item, i) => [
|
||||
hsn && !hsnCol && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: r.fine }] : item.descriptionLines,
|
||||
...(hsnCol ? [hsnCol.values[i]] : []),
|
||||
rateCell(item.rateText, rateW, r.tableBody),
|
||||
rateCell(item, rateW, r.tableBody),
|
||||
item.qtyText,
|
||||
item.amountText,
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColSpec } from "../../engine/columns";
|
||||
import type { PageFrame } from "../../engine/geometry";
|
||||
import { getMeasurer, measureToken } from "../../engine/measure";
|
||||
import { getMeasurer, measureToken, wrapAwareTexts } from "../../engine/measure";
|
||||
import { textHeight, textLines } from "../../engine/textplan";
|
||||
import { DOME_BOX } from "../../decor/Asterisk";
|
||||
import { fitSize } from "../../blocks/FitText";
|
||||
@@ -68,13 +68,13 @@ export function idLines(role: TypeToken, text: string, width: number): number {
|
||||
/** Plain shrink-wrapped width of a one-line text. */
|
||||
export const widthOf = (role: TypeToken, text: string, size?: number) => Math.ceil(measureToken(getMeasurer(), role, text, size ? { size } : {})) + 1;
|
||||
|
||||
export const priceOf = (rateText: string | null) => (rateText ? (/^(.*) per (second|minute|hour|session|unit)$/.exec(rateText)?.[1] ?? rateText) : "—");
|
||||
export const priceOf = (item: { priceText: string | null }) => item.priceText ?? "—";
|
||||
|
||||
/** The table's description + HSN/SAC sub-line, qty, rate (amount over its unit) and amount cells. */
|
||||
export function tableRows(model: RenderModel, rateW: number, body: TypeToken, sub: TypeToken): TableCellValue[][] {
|
||||
const hsn = Slot.HsnSac(model);
|
||||
return model.items.map((item, i) => {
|
||||
const rate = rateCell(item.rateText, rateW, body);
|
||||
const rate = rateCell(item, rateW, body);
|
||||
const rateParts: TableCellValue = typeof rate === "string" ? rate : ([rate[0], { text: rate[1], role: sub }] as CellPart[]);
|
||||
const desc: TableCellValue = hsn && slotHeights.hasHsn(hsn.values[i]) ? [...item.descriptionLines, { text: `${hsn.header}: ${hsn.values[i]}`, role: sub }] : item.descriptionLines;
|
||||
return [desc, item.qtyText, rateParts, item.amountText];
|
||||
@@ -193,12 +193,12 @@ export function planPurple(model: RenderModel, frame: PageFrame, v: PurpleVarian
|
||||
const align: PurplePlan["align"] = ["left", "center", "right", "right"];
|
||||
const padY = (t.rowH - leading(r.tableBody)) / 2;
|
||||
const headPadY = (t.headH - leading(r.tableHead)) / 2;
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
const priced = model.items.map((i) => priceOf(i));
|
||||
const build = (left: number, base: { qty: number; rate: number; amount: number }) => {
|
||||
const tableW = round2(W - left - t.right);
|
||||
const inner = tableW - 2 * t.inset;
|
||||
const cw = {
|
||||
qty: numericColW(base.qty, r.tableBody, r.tableHead, model.items.map((i) => i.qtyText), "QTY"),
|
||||
qty: numericColW(base.qty, r.tableBody, r.tableHead, wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody), "QTY"),
|
||||
rate: numericColW(base.rate, r.tableBody, r.tableHead, priced, "RATE"),
|
||||
amount: numericColW(base.amount, r.tableBody, r.tableHead, model.items.map((i) => i.amountText), "AMOUNT"),
|
||||
};
|
||||
@@ -424,10 +424,10 @@ export function planCitrus(model: RenderModel, frame: PageFrame, v: CitrusVarian
|
||||
// --- table ----------------------------------------------------------------------------------------------------
|
||||
const t = v.table;
|
||||
const tableW = round2(W - t.left);
|
||||
const priced = model.items.map((i) => priceOf(i.rateText));
|
||||
const priced = model.items.map((i) => priceOf(i));
|
||||
const amountNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.amountText)), measureToken(m, r.headNum, "AMOUNT"))) + 14 + 4;
|
||||
const rateNeed = Math.ceil(Math.max(0, ...priced.map((p) => measureToken(m, r.tableBody, p)), measureToken(m, r.headNum, "RATE"))) + 14 + 4;
|
||||
const qtyNeed = Math.ceil(Math.max(0, ...model.items.map((i) => measureToken(m, r.tableBody, i.qtyText)), measureToken(m, r.headNum, "QTY"))) + 6;
|
||||
const qtyNeed = Math.ceil(Math.max(0, ...wrapAwareTexts(model.items.map((i) => i.qtyText), r.tableBody).map((t) => measureToken(m, r.tableBody, t)), measureToken(m, r.headNum, "QTY"))) + 6;
|
||||
const amount = Math.max(t.cols.amount, amountNeed);
|
||||
const rate = Math.max(t.cols.rate, rateNeed);
|
||||
const qty = Math.max(Math.min(36, panelW), round2(panelW - rate - amount));
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"classic-a4-tax-3": {
|
||||
"pages": 1,
|
||||
"fingerprint": "87b12a5c1b1a9b45ac6a8fd5862c8e7b5de8d203731fe4e485872dea5bbb07cd",
|
||||
"fingerprint": "3bc384c321777db5a513f85f2b24f378523e3ddf5f3fadb08ae97e68198ed4e2",
|
||||
"invoiceNumber": "ST/2026-001",
|
||||
"totalText": "₹8,625.80",
|
||||
"totalText": "₹22,077.80",
|
||||
"pageLabel": "Page 1 of 1",
|
||||
"textHashes": [
|
||||
"ddda2419b84480c00d8ed76088898277711f448ef11c2383a48494ecd0e4c524"
|
||||
"e5b4e97805ecb9a50e26694554f810c89cbcd43fba566b27f0f8255a498f753c"
|
||||
],
|
||||
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per hour 3 ₹3,600.00 Subtotal ₹7,310.00 Taxable value ₹7,310.00 CGST (9%) ₹657.90 SGST (9%) ₹657.90 Total ₹8,625.80 Amount in words: Indian Rupees Eight Thousand Six Hundred Twenty Five and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1"
|
||||
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A GSTIN: 29AAAAA0000A1Z5 TAX INVOICE Invoice No. ST/2026-001 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 GSTIN: 29ABCDE1234F1Z5 Place of Supply: 29 – Karnataka S . N O D E S C R I P TI O N H S N /S AC U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 998397 ₹1,200.00 per hour 1 hr ₹1,200.00 2 Voice-over session 2 - narration for module 2 998397 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 998397 ₹1,200.00 per km 12.5 km ₹15,000.00 Subtotal ₹18,710.00 Taxable value ₹18,710.00 CGST (9%) ₹1,683.90 SGST (9%) ₹1,683.90 Total ₹22,077.80 Amount in words: Indian Rupees Twenty Two Thousand Seventy Seven and Eighty Paise Only Tax is payable on reverse charge: No PAY T O Selftest Studio State Bank of India Account No.: 00000000000 Branch: 000000 IFSC: SBIN0000000 Selftest Studio Proprietor Computer-generated invoice; no signature is required. Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 1"
|
||||
},
|
||||
"classic-letter-unreg-60": {
|
||||
"pages": 3,
|
||||
"fingerprint": "6999be0875f78e2c87aee1fd0ffa1dfe651faa7a7c0a9b4c2f78c1bdff53c7d4",
|
||||
"fingerprint": "6d5ee73ce95af3f8871a4634e50a082ca9f34865ba4a5ffcb541dbae8d132719",
|
||||
"invoiceNumber": "ST/2026-002",
|
||||
"totalText": "₹1,56,000.00",
|
||||
"totalText": "₹17,04,000.00",
|
||||
"pageLabel": "Page 1 of 3",
|
||||
"textHashes": [
|
||||
"4a0cf8a7c1565037edd4d9941251e030fd9e6fca25845b971465e5cf9d916936",
|
||||
"870e9a70c0546550d002e5eb5728f342b1d235094c6b645ddda68a6306b33f7b",
|
||||
"fca30345bc73289cdcad200f888f917ff9a5386cdd64e41ea7dccc64f2b7e5e0"
|
||||
"314a9e6ff0e6736435b56bb96014c2d7e8b2398ab7f7904c176a90628015154c",
|
||||
"fee554a8b908951fc5021a1adfc47b245da8a622c35e64770bfd8f38b9b6db70",
|
||||
"3c4fc1cbba3081b4dcef564b31e3c4cac0aff951923c747ec63a4ce0fd4811e9"
|
||||
],
|
||||
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per hour 3 ₹3,600.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per hour 2 ₹2,400.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per hour 3 ₹3,600.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per hour 2 ₹2,400.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per hour 3 ₹3,600.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per hour 2 ₹2,400.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3"
|
||||
"firstPageText": "Selftest Studio 1 Test Lane, Test Town, Bangalore - 560005 selftest@example.com 9000000000 PAN: AAAAA0000A INVOICE Invoice No. ST/2026-002 Date 15 Sept 2026 Due Date 15 Oct 2026 B I L L E D T O Acme Learning Pvt Ltd 42 MG Road Bangalore 560001 S . N O D E S C R I P TI O N U N IT P R I C E QT Y A M O U N T 1 Voice-over session 1 - narration for module 1 ₹1,200.00 per hour 1 hr ₹1,200.00 2 Voice-over session 2 - narration for module 2 — 1 ₹2,510.00 3 Voice-over session 3 - narration for module 3 ₹1,200.00 per km 12.5 km ₹15,000.00 4 Voice-over session 4 - narration for module 4 — 1 ₹2,530.00 5 Voice-over session 5 - narration for module 5 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 6 Voice-over session 6 - narration for module 6 — 1 ₹2,550.00 7 Voice-over session 7 - narration for module 7 ₹1,200.00 per hour 1 hr ₹1,200.00 8 Voice-over session 8 - narration for module 8 — 1 ₹2,570.00 9 Voice-over session 9 - narration for module 9 ₹1,200.00 per km 12.5 km ₹15,000.00 10 Voice-over session 10 - narration for module 10 — 1 ₹2,590.00 11 Voice-over session 11 - narration for module 11 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 12 Voice-over session 12 - narration for module 12 — 1 ₹2,610.00 13 Voice-over session 13 - narration for module 13 ₹1,200.00 per hour 1 hr ₹1,200.00 14 Voice-over session 14 - narration for module 14 — 1 ₹2,630.00 15 Voice-over session 15 - narration for module 15 ₹1,200.00 per km 12.5 km ₹15,000.00 16 Voice-over session 16 - narration for module 16 — 1 ₹2,650.00 17 Voice-over session 17 - narration for module 17 ₹1,200.00 per sq ft 121.5 sq ft ₹1,45,800.00 18 Voice-over session 18 - narration for module 18 — 1 ₹2,670.00 19 Voice-over session 19 - narration for module 19 ₹1,200.00 per hour 1 hr ₹1,200.00 Selftest Studio Generated with Voiced — a Bongbetic product Page 1 of 3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,21 @@ const BANK: BankAccount = {
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
// Rate lines cycle through units so the webview self-test also measures "hr", "km" and "sq ft" text.
|
||||
const UNIT_CYCLE: Array<{ unit: string; quantity: (i: number) => number }> = [
|
||||
{ unit: "hour", quantity: (i) => 1 + (i % 3) },
|
||||
{ unit: "km", quantity: (i) => 10.5 + (i % 3) },
|
||||
{ unit: "sqft", quantity: (i) => 120.5 + (i % 3) },
|
||||
];
|
||||
|
||||
const items = (n: number, hsn: boolean): InvoiceItem[] =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: i + 1,
|
||||
description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
|
||||
mode: i % 2 === 0 ? "rate" : "fixed",
|
||||
rate: 1200,
|
||||
unit: "hour",
|
||||
quantity: 1 + (i % 3),
|
||||
unit: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].unit,
|
||||
quantity: UNIT_CYCLE[(i >> 1) % UNIT_CYCLE.length].quantity(i),
|
||||
amount: 2500 + i * 10,
|
||||
sortOrder: i,
|
||||
...(hsn ? { hsnSac: "998397" } : {}),
|
||||
|
||||
@@ -50,6 +50,7 @@ import PdfPreview from "../components/PdfPreview";
|
||||
import RecordPaymentModal from "../components/RecordPaymentModal";
|
||||
import { useToast } from "../components/ToastProvider";
|
||||
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
|
||||
import { quantityText } from "../lib/units";
|
||||
|
||||
interface Props {
|
||||
invoiceId: number;
|
||||
@@ -456,7 +457,7 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
|
||||
<span className="voiced-detail__pre">{item.description}</span>
|
||||
{item.mode === "rate" ? (
|
||||
<div className="voiced-muted">
|
||||
{item.quantity} {item.unit} × ₹{formatAmount(item.rate)}
|
||||
{quantityText(item.quantity, item.unit)} × ₹{formatAmount(item.rate)}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
|
||||
+27
-17
@@ -34,7 +34,8 @@ import {
|
||||
} from "@carbon/icons-react";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { api } from "../lib/api";
|
||||
import { EMPTY_ITEM, INDIAN_STATES, UNIT_LABELS } from "../lib/types";
|
||||
import { EMPTY_ITEM, INDIAN_STATES } from "../lib/types";
|
||||
import { isValidUnit } from "../lib/units";
|
||||
import type {
|
||||
BankAccount,
|
||||
Client,
|
||||
@@ -44,7 +45,6 @@ import type {
|
||||
InvoiceInput,
|
||||
InvoiceItem,
|
||||
LineMode,
|
||||
LineUnit,
|
||||
Settings,
|
||||
TaxType,
|
||||
} from "../lib/types";
|
||||
@@ -57,6 +57,8 @@ import { formToPreviewInvoice } from "../lib/previewInvoice";
|
||||
import { fromDraftPayload, toDraftPayload, type InvoiceFormState } from "../lib/draft";
|
||||
import { SHORTCUT_LIST, useShortcuts } from "../hooks/useShortcuts";
|
||||
import PageSetupControls from "../components/PageSetupControls";
|
||||
import QuantityInput from "../components/QuantityInput";
|
||||
import UnitPicker from "../components/UnitPicker";
|
||||
import { useInvoicePdf } from "../hooks/useInvoicePdf";
|
||||
import { useInvoiceExport } from "../hooks/useInvoiceExport";
|
||||
import { autoPushIssued } from "../lib/erpnextUi";
|
||||
@@ -257,6 +259,12 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
? "cgst_sgst"
|
||||
: "igst";
|
||||
const clientGstinError = gstinFullError(clientGstin);
|
||||
// Quantity boxes that hold text which is not a quantity; they keep the last good value, so issuing must wait.
|
||||
const badQuantities = useRef(new Set<string>());
|
||||
const markQuantity = (id: string, ok: boolean) => {
|
||||
if (ok) badQuantities.current.delete(id);
|
||||
else badQuantities.current.add(id);
|
||||
};
|
||||
const totals = useMemo(
|
||||
() => computeTotals(items, discount, effectiveTaxType, taxRate),
|
||||
[items, discount, effectiveTaxType, taxRate],
|
||||
@@ -614,6 +622,14 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
setError(`Client GSTIN: ${clientGstinError}`);
|
||||
return;
|
||||
}
|
||||
if (badQuantities.current.size > 0) {
|
||||
setError("A quantity is not a number. Fix the line items marked in red.");
|
||||
return;
|
||||
}
|
||||
if (items.some((i) => i.mode === "rate" && !isValidUnit(i.unit))) {
|
||||
setError("A line item has no valid unit. Pick one, or type a custom unit.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
exp.dismiss();
|
||||
@@ -997,28 +1013,22 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
|
||||
onChange={(_e, { value }) => updateItem(index, { rate: Number(value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: "0 1 140px" }}>
|
||||
<Select
|
||||
<div style={{ flex: "0 1 160px" }}>
|
||||
<UnitPicker
|
||||
id={`unit-${index}`}
|
||||
disabled={locked}
|
||||
labelText="Unit"
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(index, { unit: e.target.value as LineUnit })}
|
||||
>
|
||||
{Object.entries(UNIT_LABELS).map(([value, text]) => (
|
||||
<SelectItem key={value} value={value} text={text} />
|
||||
))}
|
||||
</Select>
|
||||
onChange={(unit) => updateItem(index, { unit })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: "0 1 110px" }}>
|
||||
<NumberInput
|
||||
<div style={{ flex: "0 1 130px" }}>
|
||||
<QuantityInput
|
||||
id={`qty-${index}`}
|
||||
disabled={locked}
|
||||
label="Quantity"
|
||||
min={0}
|
||||
step={1}
|
||||
value={item.quantity}
|
||||
onChange={(_e, { value }) => updateItem(index, { quantity: Number(value) || 0 })}
|
||||
unit={item.unit}
|
||||
onChange={(quantity) => updateItem(index, { quantity })}
|
||||
onValidity={(ok) => markQuantity(`qty-${index}`, ok)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user