feat(ui): grouped thousands separators for price/amount inputs
Price fields showed raw digits (1490000) while typing — hard to read for Toman amounts. New shared MoneyInput groups as you type (1,490,000), accepts Persian/Arabic digits, and reports a raw digit string so callers keep parsing unchanged. Applied to menu item price, branch price override, expense amount, and payment-correction replacement amount. Displays already group via formatCurrency (incl. the QR guest-menu preview). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { isoTodayTehran } from "@/lib/reports/analytics";
|
|||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { MoneyInput } from "@/components/ui/money-input";
|
||||||
import { JalaliDateField } from "@/components/ui/jalali-date-field";
|
import { JalaliDateField } from "@/components/ui/jalali-date-field";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -291,14 +292,10 @@ export function ExpensesScreen() {
|
|||||||
</select>
|
</select>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<LabeledField label={t("amount")} htmlFor="exp-amount">
|
<LabeledField label={t("amount")} htmlFor="exp-amount">
|
||||||
<Input
|
<MoneyInput
|
||||||
id="exp-amount"
|
id="exp-amount"
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
dir="ltr"
|
|
||||||
className="text-end"
|
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onValueChange={setAmount}
|
||||||
/>
|
/>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<LabeledField label={t("note")} htmlFor="exp-note">
|
<LabeledField label={t("note")} htmlFor="exp-note">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/lib/api/branch-menu";
|
} from "@/lib/api/branch-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { MoneyInput } from "@/components/ui/money-input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
import { MenuItemLabels } from "@/components/menu/menu-item-labels";
|
||||||
@@ -164,11 +165,11 @@ export function BranchMenuOverrides({
|
|||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{canOverridePrice ? (
|
{canOverridePrice ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Input
|
<MoneyInput
|
||||||
className="h-8 w-28 text-xs"
|
className="h-8 w-28 text-xs"
|
||||||
value={priceDraft[row.id] ?? String(row.effectivePrice)}
|
value={priceDraft[row.id] ?? String(row.effectivePrice)}
|
||||||
onChange={(e) =>
|
onValueChange={(raw) =>
|
||||||
setPriceDraft((d) => ({ ...d, [row.id]: e.target.value }))
|
setPriceDraft((d) => ({ ...d, [row.id]: raw }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { PageHeader } from "@/components/layout/page-header";
|
|||||||
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
import { MediaPairUpload } from "@/components/media/media-pair-upload";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { MoneyInput } from "@/components/ui/money-input";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -872,13 +873,10 @@ export function MenuAdminScreen() {
|
|||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<LabeledField label={t("price")} htmlFor="modal-item-price">
|
<LabeledField label={t("price")} htmlFor="modal-item-price">
|
||||||
<Input
|
<MoneyInput
|
||||||
id="modal-item-price"
|
id="modal-item-price"
|
||||||
value={itemForm.price}
|
value={itemForm.price}
|
||||||
onChange={(e) => setItemForm((f) => ({ ...f, price: e.target.value }))}
|
onValueChange={(raw) => setItemForm((f) => ({ ...f, price: raw }))}
|
||||||
inputMode="numeric"
|
|
||||||
dir="ltr"
|
|
||||||
className="text-end"
|
|
||||||
/>
|
/>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
<LabeledField label={t("discountPercent")} htmlFor="modal-item-discount">
|
<LabeledField label={t("discountPercent")} htmlFor="modal-item-discount">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { formatCurrency, formatNumber } from "@/lib/format";
|
|||||||
import { isoTodayTehran } from "@/lib/reports/analytics";
|
import { isoTodayTehran } from "@/lib/reports/analytics";
|
||||||
import type { Order, PaymentLine } from "@/lib/api/types";
|
import type { Order, PaymentLine } from "@/lib/api/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { MoneyInput } from "@/components/ui/money-input";
|
||||||
import { JalaliDateField } from "@/components/ui/jalali-date-field";
|
import { JalaliDateField } from "@/components/ui/jalali-date-field";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -395,14 +395,11 @@ function CorrectionDialog({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Input
|
<MoneyInput
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={0}
|
|
||||||
placeholder={t("amount")}
|
placeholder={t("amount")}
|
||||||
className="h-9 flex-1 tabular-nums"
|
className="h-9 flex-1"
|
||||||
value={r.amount}
|
value={r.amount}
|
||||||
onChange={(e) => patchReplacement(i, { amount: e.target.value })}
|
onValueChange={(raw) => patchReplacement(i, { amount: raw })}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Money input for Toman amounts: shows grouped thousands separators while the
|
||||||
|
* user types (e.g. 1490000 → 1,490,000) but reports a raw digit string via
|
||||||
|
* onValueChange so callers keep parsing with Number()/parseFloat() unchanged.
|
||||||
|
* Accepts Persian/Arabic digits on input and normalizes them to Latin.
|
||||||
|
*/
|
||||||
|
export type MoneyInputProps = Omit<
|
||||||
|
React.ComponentProps<typeof Input>,
|
||||||
|
"value" | "onChange" | "inputMode" | "type"
|
||||||
|
> & {
|
||||||
|
/** Raw digit string, e.g. "1490000" (no separators). */
|
||||||
|
value: string;
|
||||||
|
/** Called with the normalized raw digit string. */
|
||||||
|
onValueChange: (rawDigits: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
|
||||||
|
const ARABIC_DIGITS = "٠١٢٣٤٥٦٧٨٩";
|
||||||
|
|
||||||
|
function toRawDigits(input: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (const ch of input) {
|
||||||
|
const fa = PERSIAN_DIGITS.indexOf(ch);
|
||||||
|
const ar = ARABIC_DIGITS.indexOf(ch);
|
||||||
|
if (ch >= "0" && ch <= "9") out += ch;
|
||||||
|
else if (fa !== -1) out += String(fa);
|
||||||
|
else if (ar !== -1) out += String(ar);
|
||||||
|
// ignore separators, spaces, and anything else
|
||||||
|
}
|
||||||
|
// strip leading zeros but keep a single "0"
|
||||||
|
out = out.replace(/^0+(?=\d)/, "");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(raw: string): string {
|
||||||
|
if (!raw) return "";
|
||||||
|
return Number(raw).toLocaleString("en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoneyInput = React.forwardRef<HTMLInputElement, MoneyInputProps>(
|
||||||
|
({ value, onValueChange, className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
value={group(value)}
|
||||||
|
onChange={(e) => onValueChange(toRawDigits(e.target.value))}
|
||||||
|
inputMode="numeric"
|
||||||
|
dir="ltr"
|
||||||
|
className={cn("text-end tabular-nums", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
MoneyInput.displayName = "MoneyInput";
|
||||||
Reference in New Issue
Block a user