diff --git a/package-lock.json b/package-lock.json index 5878e7b..869d22d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "lonc-web", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lonc-web", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { + "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", "bootstrap": "^5.3.3" }, @@ -1099,6 +1100,41 @@ "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", "license": "MIT" }, + "node_modules/@zxing/browser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz", + "integrity": "sha512-4Lmrn/il4+UNb87Gk8h1iWnhj39TASEHpd91CwwSJtY5u+wa0iH9qS0wNLAWbNVYXR66WmT5uiMhZ7oVTrKfxw==", + "license": "MIT", + "optionalDependencies": { + "@zxing/text-encoding": "^0.9.0" + }, + "peerDependencies": { + "@zxing/library": "^0.21.0" + } + }, + "node_modules/@zxing/library": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", + "integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==", + "license": "MIT", + "peer": true, + "dependencies": { + "ts-custom-error": "^3.2.1" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/alpinejs": { "version": "3.15.11", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.11.tgz", @@ -1601,6 +1637,16 @@ "node": ">=14.0.0" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 7ad0a56..5360215 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lonc-web", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "scripts": { @@ -12,6 +12,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", "bootstrap": "^5.3.3" }, diff --git a/src/api/stock.js b/src/api/stock.js index f29e018..fea63d7 100644 --- a/src/api/stock.js +++ b/src/api/stock.js @@ -68,6 +68,18 @@ function normalizeUpsertResponse(payload) { }; } +function normalizeIdentifierLookupResponse(payload) { + return { + status: payload?.status || null, + source: payload?.source || null, + cacheHit: Boolean(payload?.cache_hit), + identifierCode: payload?.identifier_code || null, + identifierType: payload?.identifier_type || null, + item: payload?.item || null, + payloadFetchedAt: payload?.payload_fetched_at || null, + }; +} + export async function previewItemUpsert(store, body) { const payload = await apiRequest(store, `${getPath('items')}/upsert`, { method: 'POST', @@ -88,6 +100,17 @@ export async function applyItemUpsert(store, body) { return normalizeUpsertResponse(payload); } +export async function lookupItemByIdentifier(store, identifierCode) { + const payload = await apiRequest(store, `${getPath('items')}/lookup`, { + method: 'POST', + body: { + identifier_code: String(identifierCode || '').trim(), + }, + }); + + return normalizeIdentifierLookupResponse(payload); +} + export async function updateStockItem(store, uuidB64, body) { const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, { method: 'POST', diff --git a/src/features/labels/identifier-lookup-mapper.js b/src/features/labels/identifier-lookup-mapper.js new file mode 100644 index 0000000..01731b0 --- /dev/null +++ b/src/features/labels/identifier-lookup-mapper.js @@ -0,0 +1,156 @@ +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function normalizedText(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function nonEmptyText(value) { + const text = normalizedText(value); + return text ? text : null; +} + +function normalizedNumberText(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + + if (typeof value === 'string') { + const text = value.trim(); + if (!text) { + return null; + } + const parsed = Number(text); + if (Number.isFinite(parsed)) { + return String(parsed); + } + } + + return ''; +} + +function parseIsoDate(isoDate) { + if (!ISO_DATE_PATTERN.test(String(isoDate || ''))) { + return null; + } + + const [year, month, day] = String(isoDate).split('-').map(Number); + const parsed = new Date(year, month - 1, day); + + if ( + parsed.getFullYear() !== year + || parsed.getMonth() !== month - 1 + || parsed.getDate() !== day + ) { + return null; + } + + return parsed; +} + +function addDaysToIsoDate(isoDate, days) { + const parsed = parseIsoDate(isoDate); + if (!parsed) { + return ''; + } + + parsed.setDate(parsed.getDate() + days); + const year = parsed.getFullYear(); + const month = String(parsed.getMonth() + 1).padStart(2, '0'); + const day = String(parsed.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function diffIsoDays(fromIsoDate, toIsoDate) { + const fromDate = parseIsoDate(fromIsoDate); + const toDate = parseIsoDate(toIsoDate); + if (!fromDate || !toDate) { + return null; + } + + const millisecondsPerDay = 24 * 60 * 60 * 1000; + const diff = Math.round((toDate - fromDate) / millisecondsPerDay); + return diff >= 0 ? diff : null; +} + +function nonNegativeDays(value) { + if (value == null) { + return null; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + + return Math.round(parsed); +} + +export function deriveLookupExpirationDays(lookupItem) { + const explicitDays = nonNegativeDays(lookupItem?.expiration_days); + if (explicitDays !== null) { + return explicitDays; + } + + return diffIsoDays(lookupItem?.date, lookupItem?.expire_date); +} + +export function mapLookupItemToForm({ + form, + lookupItem, + locations = [], +}) { + const nextForm = { ...form }; + let nextLocationSearch = null; + let didUpdate = false; + + const setField = (targetField, value) => { + if (nextForm[targetField] === value) { + return; + } + + nextForm[targetField] = value; + didUpdate = true; + }; + + const textValue = (sourceField) => normalizedText(lookupItem?.[sourceField]); + const numberValue = (sourceField) => normalizedNumberText(lookupItem?.[sourceField]); + + setField('identifierCode', textValue('identifier_code')); + setField('name', textValue('name')); + setField('description', textValue('description')); + setField('level', textValue('level')); + setField('quantity', numberValue('quantity_initial')); + setField('uom', textValue('uom_symbol')); + setField('energy', numberValue('calories')); + setField('energyUnit', textValue('calories_unit')); + setField('externalSource', textValue('external_source')); + setField('externalId', textValue('external_id')); + + setField('search', nextForm.name); + + const expirationDays = deriveLookupExpirationDays(lookupItem); + if (expirationDays !== null) { + setField('expireDays', String(expirationDays)); + setField('expirationDate', addDaysToIsoDate(nextForm.productionDate, expirationDays)); + } else { + setField('expireDays', ''); + setField('expirationDate', ''); + } + + const locationUuid = nonEmptyText(lookupItem?.location_initial_uuid_b64); + if (locationUuid) { + const matchingLocation = locations.find((entry) => entry.uuid_b64 === locationUuid); + if (matchingLocation) { + setField('locationId', String(matchingLocation.id)); + if (nextLocationSearch !== matchingLocation.name) { + nextLocationSearch = matchingLocation.name; + } + } + } + + return { + form: nextForm, + locationSearch: nextLocationSearch, + didUpdate, + }; +} diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js index a7c09e6..1d592e4 100644 --- a/src/features/labels/label-create-page.js +++ b/src/features/labels/label-create-page.js @@ -1,8 +1,11 @@ import { applyItemUpsert, + lookupItemByIdentifier, previewItemUpsert, searchItemDefinitions, } from '../../api/stock.js'; +import { BrowserMultiFormatReader } from '@zxing/browser'; +import { mapLookupItemToForm } from './identifier-lookup-mapper.js'; import { fetchLocations } from '../../api/locations.js'; import { formatPrintErrorMessage, @@ -54,28 +57,75 @@ export function renderLabelCreatePage() {