Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions frontend/src/app/(dashboard)/pos/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { useFormatCurrency } from '@/hooks/useFormatCurrency';
import { useSupportTicketStatus } from '@/hooks/useSupportTicketStatus';
import { useSupportDiagnosticsPreview } from '@/hooks/useSupportDiagnosticsPreview';
import { getCurrencySymbol, getCountryByCode } from '@/lib/countries';
import { resolveScannedProduct } from '@/lib/scale-barcode';
import {
buildAppendItemsFingerprint,
clearAppendAttempt,
Expand All @@ -49,10 +50,6 @@ import {
const PREPAID_ATTEMPT_STORAGE_KEY = 'flo.prepaid.checkout.attempt';
const POSTPAID_ATTEMPT_STORAGE_KEY = 'flo.postpaid.order.attempt';

function normalizeBarcode(value: string | null | undefined) {
return value?.trim() || '';
}

interface PostpaidAttempt {
userId: string;
fingerprint: string;
Expand Down Expand Up @@ -415,10 +412,10 @@ export default function POSPage() {
|| !!paymentBill || showCustomerPrompt || showPrepaidCheckout;

useBarcodeScanner((code) => {
const normalizedCode = normalizeBarcode(code);
const product = products.find((p) => normalizeBarcode(p.barcode) === normalizedCode);
if (product) {
handleProductClick(product);
const scan = resolveScannedProduct(code, products);
if (scan) {
if (scan.scaleBarcode) cart.addItem(scan.product, scan.quantity);
else handleProductClick(scan.product);
} else {
toast.error(t('barcodeNotFound', { code }));
}
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/app/(dashboard)/products/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default function ProductsPage() {
const [addonList, setAddonList] = useState<{ id?: number | string; name: string; price: number; is_active?: boolean }[]>([]);
const [form, setForm] = useState({
name: '', category_id: '', price: '', cost_price: '', cb_percent: '', sku: '', barcode: '',
sale_unit: 'each' as Product['sale_unit'], allow_fractional_quantity: false, weight_precision: '3',
tax_category_id: '', tax_behavior: 'country_default', description: '',
track_inventory: false, stock_quantity: '0', low_stock_threshold: '5', is_active: true,
tags: [] as string[],
Expand Down Expand Up @@ -238,6 +239,7 @@ export default function ProductsPage() {
const resetForm = () => {
setForm({
name: '', category_id: '', price: '', cost_price: '', cb_percent: '', sku: '', barcode: '',
sale_unit: 'each', allow_fractional_quantity: false, weight_precision: '3',
tax_category_id: '', tax_behavior: 'country_default', description: '',
track_inventory: false, stock_quantity: '0', low_stock_threshold: '5', is_active: true,
tags: [], customTag: '', addon_group_ids: [], image_url: null,
Expand All @@ -263,6 +265,9 @@ export default function ProductsPage() {
cb_percent: product.cb_percent === null || product.cb_percent === undefined ? '' : String(product.cb_percent),
sku: product.sku || '',
barcode: product.barcode || '',
sale_unit: product.sale_unit || 'each',
allow_fractional_quantity: !!product.allow_fractional_quantity,
weight_precision: String(product.weight_precision ?? 3),
tax_category_id: product.tax_category_id || '',
tax_behavior: product.tax_behavior || 'country_default',
description: product.description || '',
Expand Down Expand Up @@ -298,6 +303,9 @@ export default function ProductsPage() {
cb_percent: cbPercentVal,
sku: form.sku || null,
barcode: form.barcode || null,
sale_unit: form.sale_unit,
allow_fractional_quantity: form.allow_fractional_quantity,
weight_precision: Number(form.weight_precision),
tax_category_id: form.tax_category_id || null,
tax_behavior: form.tax_category_id ? form.tax_behavior : 'country_default',
description: form.description || null,
Expand Down Expand Up @@ -703,6 +711,48 @@ export default function ProductsPage() {
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand outline-none font-mono" />
<p className="text-xs text-gray-400 mt-1">{t('fieldBarcodeHint')}</p>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('fieldSaleUnit')}</label>
<select
value={form.sale_unit}
onChange={(e) => {
const saleUnit = e.target.value as Product['sale_unit'];
setForm({
...form,
sale_unit: saleUnit,
allow_fractional_quantity: saleUnit === 'each' ? false : true,
});
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand outline-none"
>
<option value="each">{t('saleUnitEach')}</option>
<option value="kg">{t('saleUnitKg')}</option>
<option value="g">{t('saleUnitG')}</option>
<option value="lb">{t('saleUnitLb')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('fieldWeightPrecision')}</label>
<input
type="number"
min="0"
max="4"
value={form.weight_precision}
onChange={(e) => setForm({ ...form, weight_precision: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand outline-none"
/>
</div>
<label className="flex items-center gap-2 pt-7">
<input
type="checkbox"
checked={form.allow_fractional_quantity}
onChange={(e) => setForm({ ...form, allow_fractional_quantity: e.target.checked })}
className="rounded border-gray-300 text-brand focus:ring-brand"
/>
<span className="text-sm text-gray-700">{t('fieldAllowFractionalQuantity')}</span>
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('priceLabel', { currency })}<span className="text-red-500 ms-1">*</span></label>
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/components/pos/ProductGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import api from '@/lib/api';
import { useTranslations } from 'use-intl';
import { parseDbTimestamp } from '@/lib/utils';
import { useFormatCurrency } from '@/hooks/useFormatCurrency';
import { resolveScannedProduct } from '@/lib/scale-barcode';

const CATEGORY_COLORS: Record<string, { bg: string; text: string; border: string; activeBg: string; activeText: string }> = {
red: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', activeBg: 'bg-red-500', activeText: 'text-white' },
Expand All @@ -37,10 +38,6 @@ function getCategoryColorClasses(color: string | null | undefined) {
return CATEGORY_COLORS[color.toLowerCase()] || null;
}

function normalizeBarcode(value: string | null | undefined) {
return value?.trim() || '';
}

interface Props {
categories: Category[];
products: Product[];
Expand Down Expand Up @@ -90,9 +87,10 @@ export default function ProductGrid({
// action into this field works regardless of typing speed.
const trimmed = search.trim();
if (!trimmed) return;
const match = products.find((p) => normalizeBarcode(p.barcode) === trimmed);
const match = resolveScannedProduct(trimmed, products);
if (match) {
onProductClick(match);
if (match.scaleBarcode) cart.addItem(match.product, match.quantity);
else onProductClick(match.product);
setSearch('');
}
}}
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -824,16 +824,19 @@
"fieldBarcode": "Barcode",
"fieldBarcodeHint": "Scan this product at POS to add it to the cart directly.",
"fieldBarcodePlaceholder": "Scan or type a barcode",
"fieldAllowFractionalQuantity": "Fractional quantity",
"fieldCategory": "Category",
"fieldCostPrice": "Cost Price",
"fieldImage": "Product Image",
"fieldLowStockThreshold": "Low Stock Threshold",
"fieldName": "Name",
"fieldSaleUnit": "Sale unit",
"fieldSku": "SKU",
"fieldStock": "Current Stock",
"fieldTags": "Tags",
"fieldTaxType": "Tax Type",
"fieldTrackInventory": "Track Inventory",
"fieldWeightPrecision": "Precision",
"hiddenOnPos": "(Hidden on POS)",
"imageCamera": "Camera",
"imageCompressFailed": "Could not compress this image enough. Try a tighter crop or another image.",
Expand Down Expand Up @@ -865,6 +868,10 @@
"optional": "Optional",
"optionalTag": "Optional",
"priceLabel": "Price ({currency})",
"saleUnitEach": "Each",
"saleUnitG": "g",
"saleUnitKg": "kg",
"saleUnitLb": "lb",
"reassignAndDelete": "Products reassigned and category deleted",
"required": "Required",
"requiredSelection": "Required selection",
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -824,16 +824,19 @@
"fieldBarcode": "Código de barras",
"fieldBarcodeHint": "Escaneá este producto en el POS para agregarlo directamente al carrito.",
"fieldBarcodePlaceholder": "Escaneá o escribí un código de barras",
"fieldAllowFractionalQuantity": "Cantidad fraccionaria",
"fieldCategory": "Categoría",
"fieldCostPrice": "Precio de costo",
"fieldImage": "Imagen del producto",
"fieldLowStockThreshold": "Umbral de Existencias Bajas",
"fieldName": "Nombre",
"fieldSaleUnit": "Unidad de venta",
"fieldSku": "SKU",
"fieldStock": "Stock Actual",
"fieldTags": "Etiquetas y Atributos",
"fieldTaxType": "Tipo de impuesto",
"fieldTrackInventory": "Llevar control de inventario",
"fieldWeightPrecision": "Precisión",
"hiddenOnPos": "(Oculto en POS)",
"imageCamera": "Cámara",
"imageCompressFailed": "No se pudo comprimir lo suficiente esta imagen. Probá un recorte más ajustado u otra imagen.",
Expand Down Expand Up @@ -865,6 +868,10 @@
"optional": "Opcional",
"optionalTag": "Opcional",
"priceLabel": "Precio ({currency})",
"saleUnitEach": "Unidad",
"saleUnitG": "g",
"saleUnitKg": "kg",
"saleUnitLb": "lb",
"reassignAndDelete": "Productos reasignados y categoría eliminada",
"required": "Obligatorio",
"requiredSelection": "Selección obligatoria",
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/i18n/messages/fa.json
Original file line number Diff line number Diff line change
Expand Up @@ -824,16 +824,19 @@
"fieldBarcode": "بارکد",
"fieldBarcodeHint": "این کالا را در صندوق پویش کنید تا بی‌درنگ به سبد افزوده شود.",
"fieldBarcodePlaceholder": "بارکد را پویش کنید یا بنویسید",
"fieldAllowFractionalQuantity": "مقدار کسری",
"fieldCategory": "دسته",
"fieldCostPrice": "بهای خرید",
"fieldImage": "نگاره کالا",
"fieldLowStockThreshold": "آستانه کمبود موجودی",
"fieldName": "نام",
"fieldSaleUnit": "واحد فروش",
"fieldSku": "شناسه کالا",
"fieldStock": "موجودی کنونی",
"fieldTags": "برچسب‌ها",
"fieldTaxType": "گونه مالیات",
"fieldTrackInventory": "پیگیری موجودی",
"fieldWeightPrecision": "دقت",
"hiddenOnPos": "(پنهان در صندوق)",
"imageCamera": "دوربین",
"imageCompressFailed": "این تصویر به اندازه کافی فشرده نشد. برش فشرده‌تر یا تصویر دیگری را بیازمایید.",
Expand Down Expand Up @@ -865,6 +868,10 @@
"optional": "اختیاری",
"optionalTag": "اختیاری",
"priceLabel": " ({currency}) قیمت ",
"saleUnitEach": "عدد",
"saleUnitG": "گرم",
"saleUnitKg": "کیلوگرم",
"saleUnitLb": "پوند",
"reassignAndDelete": "کالاها دوباره دسته‌بندی شدند و دسته پاک شد",
"required": "بایسته",
"requiredSelection": "گزینش بایسته",
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/i18n/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -824,16 +824,19 @@
"fieldBarcode": "Código de barras",
"fieldBarcodeHint": "Escanear este produto no PDV para adicioná-lo diretamente ao carrinho.",
"fieldBarcodePlaceholder": "Escanear ou digitar um código de barras",
"fieldAllowFractionalQuantity": "Quantidade fracionária",
"fieldCategory": "Categoria",
"fieldCostPrice": "Preço de Custo",
"fieldImage": "Imagem do Produto",
"fieldLowStockThreshold": "Limite de Estoque Baixo",
"fieldName": "Nome",
"fieldSaleUnit": "Unidade de venda",
"fieldSku": "SKU",
"fieldStock": "Estoque Atual",
"fieldTags": "Etiquetas",
"fieldTaxType": "Tipo de Imposto",
"fieldTrackInventory": "Controlar Estoque",
"fieldWeightPrecision": "Precisão",
"hiddenOnPos": "(Oculto no POS)",
"imageCamera": "Câmera",
"imageCompressFailed": "Não foi possível comprimir esta imagem o suficiente. Tente um recorte mais justo ou outra imagem.",
Expand Down Expand Up @@ -865,6 +868,10 @@
"optional": "Opcional",
"optionalTag": "Opcional",
"priceLabel": "Preço ({currency})",
"saleUnitEach": "Unidade",
"saleUnitG": "g",
"saleUnitKg": "kg",
"saleUnitLb": "lb",
"reassignAndDelete": "Produtos reatribuídos e categoria excluída",
"required": "Obrigatório",
"requiredSelection": "Seleção obrigatória",
Expand Down
74 changes: 74 additions & 0 deletions frontend/src/lib/scale-barcode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Product } from '@/lib/types';

export type ScaleBarcodeConfig = {
prefix: string;
productDigits: number;
weightDigits: number;
unit: 'grams';
};

export type ParsedScaleBarcode = {
plu: string;
quantity: number;
};

export const DEFAULT_SCALE_BARCODE_CONFIG: ScaleBarcodeConfig = {
prefix: '21',
productDigits: 5,
weightDigits: 5,
unit: 'grams',
};

export function normalizeBarcode(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}

export function parseScaleBarcode(
code: string,
config: ScaleBarcodeConfig = DEFAULT_SCALE_BARCODE_CONFIG,
): ParsedScaleBarcode | null {
const normalized = normalizeBarcode(code);
const payloadLength = config.prefix.length + config.productDigits + config.weightDigits;
if (normalized.length < payloadLength || !normalized.startsWith(config.prefix) || !/^\d+$/.test(normalized)) {
return null;
}

const pluStart = config.prefix.length;
const plu = normalized.slice(pluStart, pluStart + config.productDigits);
const weightRaw = normalized.slice(pluStart + config.productDigits, payloadLength);
const grams = Number.parseInt(weightRaw, 10);
if (!Number.isSafeInteger(grams) || grams <= 0) return null;
return { plu, quantity: grams / 1000 };
}

function roundedQuantity(value: number, precision: number | null | undefined): number {
const digits = Number.isSafeInteger(precision) ? Math.min(Math.max(Number(precision), 0), 4) : 3;
return Number(value.toFixed(digits));
}

function quantityForProductUnit(parsed: ParsedScaleBarcode, product: Product): number | null {
if (product.sale_unit === 'kg') return roundedQuantity(parsed.quantity, product.weight_precision);
if (product.sale_unit === 'g') return roundedQuantity(parsed.quantity * 1000, product.weight_precision);
if (product.sale_unit === 'lb') return roundedQuantity(parsed.quantity / 0.45359237, product.weight_precision);
return null;
}

export function resolveScannedProduct(
code: string,
products: Product[],
): { product: Product; quantity: number; scaleBarcode: ParsedScaleBarcode | null } | null {
const normalizedCode = normalizeBarcode(code);
const exact = products.find((product) => normalizeBarcode(product.barcode) === normalizedCode);
if (exact) return { product: exact, quantity: 1, scaleBarcode: null };

const parsed = parseScaleBarcode(normalizedCode);
if (!parsed) return null;
const product = products.find((candidate) => {
if (!candidate.allow_fractional_quantity) return false;
if (candidate.sale_unit !== 'kg' && candidate.sale_unit !== 'g' && candidate.sale_unit !== 'lb') return false;
return normalizeBarcode(candidate.barcode) === parsed.plu || normalizeBarcode(candidate.sku) === parsed.plu;
});
if (!product) return null;
const quantity = quantityForProductUnit(parsed, product);
return quantity ? { product, quantity, scaleBarcode: parsed } : null;
}
3 changes: 3 additions & 0 deletions frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export interface Product {
name: string;
sku: string | null;
barcode: string | null;
sale_unit: 'each' | 'kg' | 'g' | 'lb';
allow_fractional_quantity: boolean;
weight_precision: number;
description: string | null;
price: number;
cost_price: number | null;
Expand Down
Loading
Loading