Skip to content
Merged
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
30 changes: 18 additions & 12 deletions klik_spa/src/components/BarcodeScanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,16 @@ export default function BarcodeScannerModal({ onBarcodeDetected, onClose, isOpen

try {
type SupportedBarcodeFormat =
| 'code_128'
| 'code_39'
| 'ean_13'
| 'ean_8'
| 'upc_a'
| 'upc_e'
| 'qr_code'
| 'aztec'
| 'code_128'
| 'code_39'
| 'data_matrix' // ← THIS is what your medicine box uses
| 'ean_13'
| 'ean_8'
| 'pdf417'
| 'qr_code'
| 'upc_a'
| 'upc_e'

type BarcodeDetectorType = new (options?: { formats?: SupportedBarcodeFormat[] }) => {
detect(image: HTMLCanvasElement): Promise<Array<{ rawValue: string }>>
Expand All @@ -126,10 +129,12 @@ export default function BarcodeScannerModal({ onBarcodeDetected, onClose, isOpen
setError('BarcodeDetector not available in this browser.')
return
}
const barcodeDetector = new Detector({
formats: ['code_128', 'code_39', 'ean_13', 'ean_8', 'upc_a', 'upc_e', 'qr_code']
})

// const barcodeDetector = new Detector({
// formats: ['code_128', 'code_39', 'ean_13', 'ean_8', 'upc_a', 'upc_e', 'qr_code']
// })
const barcodeDetector = new Detector({
formats: ['aztec', 'code_128', 'code_39', 'data_matrix', 'ean_13', 'ean_8', 'pdf417', 'qr_code', 'upc_a', 'upc_e']
})
// Start detection loop
detectionIntervalRef.current = setInterval(async () => {
if (videoRef.current && canvasRef.current && videoRef.current.videoWidth > 0) {
Expand Down Expand Up @@ -208,7 +213,8 @@ export default function BarcodeScannerModal({ onBarcodeDetected, onClose, isOpen
<div className="flex items-center">
<CheckCircle className="text-green-600 dark:text-green-400 mr-2" size={20} />
<span className="text-green-800 dark:text-green-200 font-medium">
Barcode detected: {scannedBarcode}
Barcode detected: {scannedBarcode.length > 30 ? '(GS1 DataMatrix)' : scannedBarcode}

</span>
</div>
<p className="text-green-600 dark:text-green-400 text-sm mt-1">
Expand Down
80 changes: 58 additions & 22 deletions klik_spa/src/components/RetailPOSLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,29 +286,65 @@ export default function RetailPOSLayout() {
// Barcode scanning functionality - moved after handleAddToCart is defined
const { scanBarcode } = useBarcodeScanner(addItemToCart)

const handleBarcodeDetected = useCallback(async (barcode: string) => {
const result = await scanBarcode(barcode)
if (result) {
setShowScanner(false)
if (typeof result === 'object' && result.success && result.item_code) {
const itemCode = result.item_code
const batchId = result.matched_type === 'batch' ? result.matched_value : undefined
const serialNo = result.matched_type === 'serial' ? result.matched_value : undefined
// Add already awaited in scanBarcode; short delay so store subscribers see the new line before we apply batch/serial
setTimeout(() => {
if (batchId) {
window.dispatchEvent(new CustomEvent('cart:setBatchForItem', {
detail: { itemCode, batchId, forLastAdded: true },
}))
} else if (serialNo) {
window.dispatchEvent(new CustomEvent('cart:setSerialForItem', {
detail: { itemCode, serialNo, forLastAdded: true },
}))
}
}, 50)
}
// const handleBarcodeDetected = useCallback(async (barcode: string) => {
// const result = await scanBarcode(barcode)
// if (result) {
// setShowScanner(false)
// if (typeof result === 'object' && result.success && result.item_code) {
// const itemCode = result.item_code
// const batchId = result.matched_type === 'batch' ? result.matched_value : undefined
// const serialNo = result.matched_type === 'serial' ? result.matched_value : undefined
// // Add already awaited in scanBarcode; short delay so store subscribers see the new line before we apply batch/serial
// setTimeout(() => {
// if (batchId) {
// window.dispatchEvent(new CustomEvent('cart:setBatchForItem', {
// detail: { itemCode, batchId, forLastAdded: true },
// }))
// } else if (serialNo) {
// window.dispatchEvent(new CustomEvent('cart:setSerialForItem', {
// detail: { itemCode, serialNo, forLastAdded: true },
// }))
// }
// }, 50)
// }
// }
// }, [scanBarcode])

// REPLACE this entire handleBarcodeDetected function:

const handleBarcodeDetected = useCallback(async (barcode: string) => {
const result = await scanBarcode(barcode)
if (result) {
setShowScanner(false)
if (typeof result === 'object' && result.success && result.item_code) {
const itemCode = result.item_code

// Pull batch + serial from GS1 parsed data first (has both),
// then fall back to matched_type/matched_value for plain barcodes
const batchId =
result.gs1?.lotNumber ??
(result.matched_type === 'batch' ? result.matched_value : undefined)

const serialNo =
result.gs1?.serialNumber ??
(result.matched_type === 'serial' ? result.matched_value : undefined)

setTimeout(() => {
// Dispatch BOTH — batch first, then serial (no else if)
if (batchId) {
window.dispatchEvent(new CustomEvent('cart:setBatchForItem', {
detail: { itemCode, batchId, forLastAdded: true },
}))
}
if (serialNo) {
window.dispatchEvent(new CustomEvent('cart:setSerialForItem', {
detail: { itemCode, serialNo, forLastAdded: true },
}))
}
}, 50)
}
}, [scanBarcode])
}
}, [scanBarcode])

// Handle search input for both product search and barcode scanning
const handleSearchInput = (query: string) => {
Expand Down
201 changes: 201 additions & 0 deletions klik_spa/src/hooks/gS1parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* GS1 DataMatrix / GS1-128 Parser
*
* Handles structured barcodes like:
* 010084014965237717280131 10MP4022 211WNVHXXC68
* (with or without FNC1 / GS separators)
*/

export interface GS1ParsedData {
gtin?: string // AI 01 – 14 digits
expiryDate?: string // AI 17 – YYMMDD → parsed to "YYYY-MM-DD"
lotNumber?: string // AI 10 – variable length
serialNumber?: string // AI 21 – variable length
raw: string // original scanned string
isGS1: boolean
}

/** ASCII group separator used as FNC1 in many scanners */
const GS = '\x1d'

/**
* Fixed-length AI definitions (AI → exact field length after the 2-digit AI).
* Variable-length AIs are handled separately.
*/
const FIXED_LENGTH_AI: Record<string, number> = {
'01': 14, // GTIN
'02': 14, // GTIN of contained trade items
'11': 6, // Production date YYMMDD
'13': 6, // Packaging date YYMMDD
'15': 6, // Best before date YYMMDD
'17': 6, // Expiry date YYMMDD
'31': 8, // Net weight / variable measure
'32': 8,
'33': 8,
'34': 8,
'35': 8,
'36': 8,
}

/** Variable-length AIs we care about (max length per GS1 spec) */
const VARIABLE_LENGTH_AI: Record<string, number> = {
'10': 20, // Batch / Lot
'21': 20, // Serial number
'240': 30,
'241': 30,
'250': 30,
'251': 30,
'253': 30,
'254': 20,
'30': 8,
'37': 8,
'400': 30,
'401': 30,
'402': 17,
'403': 30,
'410': 13,
'411': 13,
'412': 13,
'413': 13,
'414': 13,
'415': 13,
'420': 20,
'421': 15,
'422': 3,
'710': 20,
'711': 20,
'712': 20,
'713': 20,
'714': 20,
}

function formatExpiryDate(yymmdd: string): string {
if (yymmdd.length !== 6) return yymmdd
const yy = yymmdd.substring(0, 2)
const mm = yymmdd.substring(2, 4)
const dd = yymmdd.substring(4, 6)
const year = parseInt(yy, 10) >= 50 ? `19${yy}` : `20${yy}`
return `${year}-${mm}-${dd}`
}

/**
* Returns true when the string looks like it could be a GS1 compound code.
* Heuristic: starts with a known 2-digit AI ("01", "10", "17", "21") optionally
* preceded by "]d2" / "]C1" symbology identifiers.
*/
export function looksLikeGS1(code: string): boolean {
// Strip common symbology identifier prefixes
const stripped = code.replace(/^\]([dCeQ][0-9A-Za-z]|d[0-9])/, '')
return /^(01|10|17|21|00|02|11|13|15|30|37|240|241|250|251|253|254|400|401|402|403|410|411|412|413|414|415|420|421|422|710|711|712|713|714)/.test(stripped)
}

export function parseGS1(raw: string): GS1ParsedData {
const result: GS1ParsedData = { raw, isGS1: false }

if (!raw || raw.length < 4) return result

// Strip symbology identifiers like ]d2, ]C1, ]e0, ]Q3
let data = raw.replace(/^\]([dCeQ][0-9A-Za-z]|d[0-9])/, '')

// Replace GS (ASCII 29) separators with a placeholder we can work with
data = data.replace(new RegExp(GS, 'g'), GS)

if (!looksLikeGS1(data)) return result

result.isGS1 = true

let pos = 0

while (pos < data.length) {
// Skip any GS separator character
if (data[pos] === GS) {
pos++
continue
}

// Try 3-digit AI first (some AIs are 3 digits)
let ai = data.substring(pos, pos + 3)
let aiLength = 3

// Fall back to 2-digit AI
if (FIXED_LENGTH_AI[ai] === undefined && VARIABLE_LENGTH_AI[ai] === undefined) {
ai = data.substring(pos, pos + 2)
aiLength = 2
}

if (!ai || aiLength > data.length - pos) break

pos += aiLength

if (FIXED_LENGTH_AI[ai] !== undefined) {
// Fixed-length field
const fieldLen = FIXED_LENGTH_AI[ai]
const value = data.substring(pos, pos + fieldLen)
pos += fieldLen

switch (ai) {
case '01': result.gtin = value; break
case '17': result.expiryDate = formatExpiryDate(value); break
}
} else if (VARIABLE_LENGTH_AI[ai] !== undefined) {
// Variable-length field: read until GS separator, next AI, or max length
const maxLen = VARIABLE_LENGTH_AI[ai]
const gsPos = data.indexOf(GS, pos)
let end: number

if (gsPos !== -1 && gsPos - pos <= maxLen) {
end = gsPos
} else {
// Without a GS separator, variable-length field boundaries are
// inherently ambiguous per GS1 spec (GS separators are required).
//
// We resolve this with a whitelist of AIs that are common on product
// labels and whose 2-digit codes are unambiguous (i.e. they won't
// appear as a digit-substring inside a typical lot/serial value):
// 01 GTIN, 11 prod-date, 13 pack-date, 15 best-before,
// 17 expiry, 21 serial
//
// Crucially, we exclude AIs like 400-415, 420-422 whose codes
// are often substrings of alphanumeric lot values (e.g. "40" in "MP4022").
//
// Scan forward; stop at the FIRST position where one of these
// unambiguous AIs begins and the remaining data satisfies its length.
const BOUNDARY_AIS: Array<[string, number | 'var']> = [
['01', 14], ['11', 6], ['13', 6], ['15', 6], ['17', 6], // fixed
['10', 'var'], ['21', 'var'], ['30', 'var'], ['37', 'var'], // variable
['240', 'var'], ['241', 'var'], ['250', 'var'], ['251', 'var'],
]

end = pos + maxLen
for (let look = pos + 1; look < Math.min(pos + maxLen, data.length - 1); look++) {
if (!/[0-9]/.test(data[look])) continue

for (const [boundaryAI, len] of BOUNDARY_AIS) {
const slice = data.substring(look, look + boundaryAI.length)
if (slice !== boundaryAI) continue

const afterAI = look + boundaryAI.length
const remaining = data.length - afterAI
const valid = len === 'var' ? remaining >= 1 : remaining >= len
if (valid) { end = look; break }
}

if (end !== pos + maxLen) break // found boundary
}
}

const value = data.substring(pos, end)
pos = end

switch (ai) {
case '10': result.lotNumber = value; break
case '21': result.serialNumber = value; break
}
} else {
// Unknown AI – skip one character and try to re-sync
pos++
}
}

return result
}
Loading
Loading