feat(app): implement Product Scan review flow, dynamic batch expiry picker, custom PDFs, PO relationships, and aligned card layouts
This commit is contained in:
1 parent
9ff4a4a922
commit
577be04308
69 files changed
+5645
-1657
No files matched your search
@@ -0,0 +1,103 @@
|
||||
const puppeteer = require('puppeteer');
|
||||
const fs = require('fs');
|
||||
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: "new",
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
|
||||
console.log("Navigating to login page...");
|
||||
await page.goto('http://localhost:3000/admin/master-data', { waitUntil: 'networkidle2' });
|
||||
|
||||
console.log("Filling login form...");
|
||||
await page.type('input[type="text"]', 'admin');
|
||||
await page.type('input[type="password"]', 'password');
|
||||
|
||||
await page.screenshot({ path: 'test_step1_login_filled.png' });
|
||||
|
||||
console.log("Clicking login...");
|
||||
await Promise.all([
|
||||
page.click('button[type="submit"]'),
|
||||
page.waitForNavigation({ waitUntil: 'networkidle0' }).catch(e => console.log('Navigation wait timeout/catch'))
|
||||
]);
|
||||
|
||||
// Wait a bit for React to render the stores table
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await page.screenshot({ path: 'test_step2_after_login.png' });
|
||||
|
||||
// Add store
|
||||
console.log("Clicking Add Store...");
|
||||
await page.evaluate(() => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const addBtn = btns.find(b => b.textContent.includes('Add Store'));
|
||||
if (addBtn) addBtn.click();
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
console.log("Filling new store form...");
|
||||
const inputs = await page.$$('input[placeholder]');
|
||||
for (const input of inputs) {
|
||||
const placeholder = await input.evaluate(el => el.getAttribute('placeholder'));
|
||||
if (placeholder === 'Kode Toko') await input.type('TEST99');
|
||||
if (placeholder === 'Nama Toko') await input.type('Toko Test 99');
|
||||
if (placeholder === 'Alamat') await input.type('Alamat Test');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test_step3_store_filled.png' });
|
||||
|
||||
console.log("Saving store...");
|
||||
await page.evaluate(() => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const saveBtn = btns.find(b => b.textContent === 'Save');
|
||||
if (saveBtn) saveBtn.click();
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await page.screenshot({ path: 'test_step4_store_saved.png' });
|
||||
|
||||
// Switch to SKUs tab
|
||||
console.log("Switching to SKUs tab...");
|
||||
await page.evaluate(() => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const skuBtn = btns.find(b => b.textContent === 'SKUs');
|
||||
if (skuBtn) skuBtn.click();
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await page.screenshot({ path: 'test_step5_skus_tab.png' });
|
||||
|
||||
console.log("Clicking Add SKU...");
|
||||
await page.evaluate(() => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const addBtn = btns.find(b => b.textContent.includes('Add SKU'));
|
||||
if (addBtn) addBtn.click();
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
console.log("Filling new SKU form...");
|
||||
const skuInputs = await page.$$('input[placeholder]');
|
||||
for (const input of skuInputs) {
|
||||
const placeholder = await input.evaluate(el => el.getAttribute('placeholder'));
|
||||
if (placeholder === 'Kode Item') await input.type('SKU99');
|
||||
if (placeholder === 'Nama Item') await input.type('Item 99');
|
||||
if (placeholder === 'Barcode') await input.type('12345');
|
||||
if (placeholder === 'Jenis Outer (e.g. DUS)') await input.type('DUS');
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test_step6_sku_filled.png' });
|
||||
|
||||
console.log("Saving SKU...");
|
||||
await page.evaluate(() => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const saveBtn = btns.find(b => b.textContent === 'Save');
|
||||
if (saveBtn) saveBtn.click();
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await page.screenshot({ path: 'test_step7_sku_saved.png' });
|
||||
|
||||
console.log("Done! Screenshots saved.");
|
||||
await browser.close();
|
||||
})();
|
||||
Generated
+134
-77
@@ -27,6 +27,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"puppeteer": "^25.3.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
@@ -1267,13 +1268,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@puppeteer/browsers": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.4.tgz",
|
||||
"integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==",
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz",
|
||||
"integrity": "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"modern-tar": "^0.7.6",
|
||||
"yargs": "^17.7.2"
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"browsers": "lib/main-cli.js"
|
||||
@@ -1282,11 +1283,15 @@
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"proxy-agent": ">=8.0.1"
|
||||
"proxy-agent": ">=8.0.1",
|
||||
"yauzl": "^2.10.0 || ^3.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"proxy-agent": {
|
||||
"optional": true
|
||||
},
|
||||
"yauzl": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2325,18 +2330,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -2799,23 +2808,24 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -2828,6 +2838,7 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
@@ -2999,9 +3010,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1624250",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz",
|
||||
"integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==",
|
||||
"version": "0.0.1638949",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz",
|
||||
"integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
@@ -3891,6 +3902,18 @@
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -4371,15 +4394,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
@@ -5096,6 +5110,19 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -5872,15 +5899,37 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.1.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz",
|
||||
"integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==",
|
||||
"node_modules/puppeteer": {
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.3.0.tgz",
|
||||
"integrity": "sha512-O1tx8S315aw8eI99HZ5ZNcVEzJ9+jKF//eO5UvfZ3cXJ6okZ5sX3Y50u7DJaM+ewEK4LqXP068tBhfRaWikj+g==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.0.4",
|
||||
"@puppeteer/browsers": "3.0.6",
|
||||
"chromium-bidi": "16.0.1",
|
||||
"devtools-protocol": "0.0.1624250",
|
||||
"devtools-protocol": "0.0.1638949",
|
||||
"lilconfig": "^3.1.3",
|
||||
"puppeteer-core": "25.3.0",
|
||||
"typed-query-selector": "^2.12.2"
|
||||
},
|
||||
"bin": {
|
||||
"puppeteer": "lib/puppeteer/node/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/puppeteer-core": {
|
||||
"version": "25.3.0",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.3.0.tgz",
|
||||
"integrity": "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@puppeteer/browsers": "3.0.6",
|
||||
"chromium-bidi": "16.0.1",
|
||||
"devtools-protocol": "0.0.1638949",
|
||||
"typed-query-selector": "^2.12.2",
|
||||
"webdriver-bidi-protocol": "0.4.2",
|
||||
"ws": "^8.21.0"
|
||||
@@ -5982,15 +6031,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
@@ -6407,23 +6447,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
@@ -6540,15 +6583,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
@@ -7112,22 +7158,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
@@ -7175,30 +7233,29 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"puppeteer": "^25.3.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
export default function MasterDataPage() {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loginError, setLoginError] = useState("");
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"stores" | "skus">("stores");
|
||||
const [stores, setStores] = useState<any[]>([]);
|
||||
const [skus, setSkus] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedToken = localStorage.getItem("adminToken");
|
||||
if (savedToken) {
|
||||
setToken(savedToken);
|
||||
fetchData(savedToken, activeTab);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoginError("");
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || "Login failed");
|
||||
|
||||
const tokenStr = data.data?.token || data.token;
|
||||
localStorage.setItem("adminToken", tokenStr);
|
||||
setToken(tokenStr);
|
||||
fetchData(tokenStr, activeTab);
|
||||
} catch (err: any) {
|
||||
setLoginError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("adminToken");
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
const fetchData = async (authToken: string, tab: "stores" | "skus") => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/master/${tab}`, {
|
||||
headers: { "Authorization": `Bearer ${authToken}` }
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
handleLogout();
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
if (tab === "stores") setStores(data.data || []);
|
||||
else setSkus(data.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-slate-950 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900/40 border border-slate-800/80 shadow-2xl backdrop-blur-md rounded-2xl p-8 w-full max-w-md">
|
||||
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent text-center mb-6">
|
||||
Admin Login
|
||||
</h1>
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-950/60 border border-slate-800 text-slate-100 placeholder-slate-600 rounded-xl p-3 focus:outline-none focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/60 transition-all duration-200 text-sm"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Enter admin username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full bg-slate-950/60 border border-slate-800 text-slate-100 placeholder-slate-650 rounded-xl p-3 focus:outline-none focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/60 transition-all duration-200 text-sm"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{loginError && (
|
||||
<div className="bg-rose-950/30 border border-rose-800/40 p-3 rounded-xl text-xs text-rose-450 flex items-center gap-2">
|
||||
<span>⚠️</span>
|
||||
<span>{loginError}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-teal-600 hover:bg-teal-500 text-slate-950 font-bold p-3 rounded-xl transition-all duration-200 shadow-lg shadow-teal-900/20 text-sm cursor-pointer"
|
||||
>
|
||||
Log In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-slate-950 p-8 text-slate-100">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8 border-b border-slate-800/60 pb-4">
|
||||
<h1 className="text-2xl font-extrabold bg-gradient-to-r from-teal-400 to-emerald-400 bg-clip-text text-transparent flex items-center gap-2">
|
||||
<span>⚙️</span> Master Data Management
|
||||
</h1>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-slate-400 hover:text-slate-100 bg-slate-900/60 hover:bg-slate-900 border border-slate-850 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mb-6 border-b border-slate-800/60 pb-px">
|
||||
<button
|
||||
className={`pb-2.5 px-4 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer ${
|
||||
activeTab === 'stores'
|
||||
? 'border-teal-500 text-teal-400'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
onClick={() => setActiveTab('stores')}
|
||||
>
|
||||
Stores
|
||||
</button>
|
||||
<button
|
||||
className={`pb-2.5 px-4 text-sm font-semibold transition-all border-b-2 -mb-px cursor-pointer ${
|
||||
activeTab === 'skus'
|
||||
? 'border-teal-500 text-teal-400'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
onClick={() => setActiveTab('skus')}
|
||||
>
|
||||
SKUs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/40 border border-slate-850 rounded-2xl p-6 shadow-xl backdrop-blur-md">
|
||||
{activeTab === 'stores' && <StoreManager stores={stores} token={token} onRefresh={() => fetchData(token, 'stores')} />}
|
||||
{activeTab === 'skus' && <SkuManager skus={skus} token={token} onRefresh={() => fetchData(token, 'skus')} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreManager({ stores, token, onRefresh }: { stores: any[], token: string, onRefresh: () => void }) {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [form, setForm] = useState({ kode_toko: "", nama_toko: "", alamat: "" });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/v1/master/stores", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message);
|
||||
setIsAdding(false);
|
||||
setForm({ kode_toko: "", nama_toko: "", alamat: "" });
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (kode: string) => {
|
||||
if (!confirm(`Delete store ${kode}?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/master/stores/${kode}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message);
|
||||
}
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold text-slate-200 flex items-center gap-2">
|
||||
<span>🏪</span> Store Master
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="bg-teal-600 hover:bg-teal-500 text-slate-950 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 shadow-md shadow-teal-900/10 cursor-pointer"
|
||||
>
|
||||
+ Add Store
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<form onSubmit={handleSubmit} className="mb-6 bg-slate-950/40 p-5 rounded-xl border border-slate-800/60">
|
||||
<h3 className="text-xs font-bold text-slate-450 uppercase tracking-wider mb-4">
|
||||
Add New Store <span className="text-[10px] text-teal-500 font-normal lowercase">(Will auto-generate account with "123" password)</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<input
|
||||
placeholder="Kode Toko"
|
||||
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
|
||||
value={form.kode_toko}
|
||||
onChange={e => setForm({...form, kode_toko: e.target.value})}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
placeholder="Nama Toko"
|
||||
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
|
||||
value={form.nama_toko}
|
||||
onChange={e => setForm({...form, nama_toko: e.target.value})}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
placeholder="Alamat"
|
||||
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
|
||||
value={form.alamat}
|
||||
onChange={e => setForm({...form, alamat: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-rose-450 text-xs mb-3 font-semibold">⚠️ {error}</div>}
|
||||
<div className="flex space-x-2">
|
||||
<button type="submit" className="bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Save</button>
|
||||
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-750 text-slate-300 px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-800/60">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead className="bg-slate-950/60 border-b border-slate-800/80 text-slate-400">
|
||||
<tr>
|
||||
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Kode Toko</th>
|
||||
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Nama Toko</th>
|
||||
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Alamat</th>
|
||||
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase w-24">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stores.map(s => (
|
||||
<tr key={s.kode_toko} className="border-b border-slate-850/50 hover:bg-slate-900/30 transition-colors">
|
||||
<td className="p-3.5 font-semibold text-slate-200 font-mono">{s.kode_toko}</td>
|
||||
<td className="p-3.5 text-slate-300 font-medium">{s.nama_toko}</td>
|
||||
<td className="p-3.5 text-slate-400 truncate max-w-xs">{s.alamat}</td>
|
||||
<td className="p-3.5">
|
||||
<button
|
||||
onClick={() => handleDelete(s.kode_toko)}
|
||||
className="text-rose-400 hover:text-rose-355 transition-colors font-bold cursor-pointer font-mono"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{stores.length === 0 && (
|
||||
<tr><td colSpan={4} className="p-6 text-center text-slate-500">No stores found.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkuManager({ skus, token, onRefresh }: { skus: any[], token: string, onRefresh: () => void }) {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [form, setForm] = useState({ no_sku: "", nama_item: "", jenis_outer: "", standar_jumlah: "1" });
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const payload = { ...form, standar_jumlah: parseInt(form.standar_jumlah) || 1 };
|
||||
const res = await fetch("/api/v1/master/skus", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message);
|
||||
setIsAdding(false);
|
||||
setForm({ no_sku: "", nama_item: "", jenis_outer: "", standar_jumlah: "1" });
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (kode: string) => {
|
||||
if (!confirm(`Delete SKU ${kode}?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/master/skus/${kode}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Authorization": `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.message);
|
||||
}
|
||||
onRefresh();
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold text-slate-200 flex items-center gap-2">
|
||||
<span>📦</span> SKU Master
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="bg-teal-600 hover:bg-teal-500 text-slate-950 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 shadow-md shadow-teal-900/10 cursor-pointer"
|
||||
>
|
||||
+ Add SKU
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<form onSubmit={handleSubmit} className="mb-6 bg-slate-950/40 p-5 rounded-xl border border-slate-800/60">
|
||||
<h3 className="text-xs font-bold text-slate-450 uppercase tracking-wider mb-4 font-mono">Add New SKU</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<input
|
||||
placeholder="No SKU"
|
||||
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
|
||||
value={form.no_sku}
|
||||
onChange={e => setForm({...form, no_sku: e.target.value})}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
placeholder="Nama Item"
|
||||
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
|
||||
value={form.nama_item}
|
||||
onChange={e => setForm({...form, nama_item: e.target.value})}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
placeholder="Jenis Outer (e.g. DUS)"
|
||||
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
|
||||
value={form.jenis_outer}
|
||||
onChange={e => setForm({...form, jenis_outer: e.target.value})}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Std Qty"
|
||||
className="bg-slate-900/60 border border-slate-800/80 text-slate-100 placeholder-slate-600 rounded-lg p-2.5 text-xs focus:outline-none focus:border-teal-500/60"
|
||||
value={form.standar_jumlah}
|
||||
onChange={e => setForm({...form, standar_jumlah: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-rose-450 text-xs mb-3 font-semibold">⚠️ {error}</div>}
|
||||
<div className="flex space-x-2">
|
||||
<button type="submit" className="bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Save</button>
|
||||
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-750 text-slate-300 px-4 py-2 rounded-lg text-xs transition-colors cursor-pointer">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-800/60">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead className="bg-slate-950/60 border-b border-slate-800/80 text-slate-400">
|
||||
<tr>
|
||||
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">No SKU</th>
|
||||
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Nama Item</th>
|
||||
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Outer</th>
|
||||
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase">Std Qty</th>
|
||||
<th className="p-3.5 font-bold font-mono text-[10px] tracking-wider uppercase w-24">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{skus.map(s => (
|
||||
<tr key={s.no_sku} className="border-b border-slate-850/50 hover:bg-slate-900/30 transition-colors">
|
||||
<td className="p-3.5 font-semibold text-slate-200 font-mono">{s.no_sku}</td>
|
||||
<td className="p-3.5 text-slate-300 font-medium">{s.nama_item}</td>
|
||||
<td className="p-3.5 text-slate-400 font-mono">{s.jenis_outer}</td>
|
||||
<td className="p-3.5 text-slate-400 font-mono">{s.standar_jumlah}</td>
|
||||
<td className="p-3.5">
|
||||
<button
|
||||
onClick={() => handleDelete(s.no_sku)}
|
||||
className="text-rose-400 hover:text-rose-350 transition-colors font-bold cursor-pointer font-mono"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{skus.length === 0 && (
|
||||
<tr><td colSpan={5} className="p-6 text-center text-slate-500">No SKUs found.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { query } from "@/db";
|
||||
import { errorResponse } from "@/utils/api-error";
|
||||
|
||||
// Separate from DO manual_labels.json - product scan ground truth only
|
||||
@@ -54,12 +56,25 @@ function normalizeDateString(dateStr: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
let purged = false;
|
||||
function readLabels(): ProductScanLabel[] {
|
||||
if (!fs.existsSync(LABELS_PATH)) {
|
||||
return [];
|
||||
}
|
||||
const raw = fs.readFileSync(LABELS_PATH, "utf8");
|
||||
return raw.trim() ? JSON.parse(raw) : [];
|
||||
if (!raw.trim()) return [];
|
||||
let labels: ProductScanLabel[] = JSON.parse(raw);
|
||||
|
||||
// Cleanup phantom uploaded-* entries once
|
||||
if (!purged) {
|
||||
const valid = labels.filter((l) => !l.filename.startsWith("uploaded-"));
|
||||
if (valid.length !== labels.length) {
|
||||
writeLabels(valid);
|
||||
labels = valid;
|
||||
}
|
||||
purged = true;
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function writeLabels(labels: ProductScanLabel[]) {
|
||||
@@ -76,23 +91,69 @@ export async function GET(req: NextRequest) {
|
||||
const filename = searchParams.get("filename");
|
||||
|
||||
if (!filename) {
|
||||
return errorResponse(400, "Filename parameter is required");
|
||||
const labels = readLabels();
|
||||
return NextResponse.json(labels);
|
||||
}
|
||||
|
||||
const safeFilename = sanitizeFilename(filename);
|
||||
const labels = readLabels();
|
||||
const existing = labels.find((l) => l.filename === safeFilename);
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(existing);
|
||||
// Inferred values from filename/directory structure
|
||||
let inferredSku = "";
|
||||
let inferredNamaItem = "";
|
||||
const parts = safeFilename.split("/");
|
||||
if (parts.length > 1) {
|
||||
const folderName = parts[0];
|
||||
const match = folderName.match(/^(\d{8})/);
|
||||
if (match) {
|
||||
inferredSku = match[1];
|
||||
} else if (/^\d{8}$/.test(folderName)) {
|
||||
inferredSku = folderName;
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty default state if not found
|
||||
if (inferredSku) {
|
||||
try {
|
||||
const dbRes = await query("SELECT nama_item FROM sku_master WHERE no_sku = $1", [inferredSku]);
|
||||
if (dbRes.rowCount > 0) {
|
||||
inferredNamaItem = dbRes.rows[0].nama_item;
|
||||
}
|
||||
} catch (dbErr) {
|
||||
console.error("Failed to query sku_master for manual label:", dbErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Inferred expiry date from sibling files in the same parent directory
|
||||
let siblingExpiry = "";
|
||||
let parentFolder = "";
|
||||
if (parts.length > 1) {
|
||||
parentFolder = parts.slice(0, -1).join("/");
|
||||
}
|
||||
if (parentFolder) {
|
||||
const sibling = labels.find(
|
||||
(l) => l.filename.startsWith(parentFolder + "/") && l.expiry_date
|
||||
);
|
||||
if (sibling) {
|
||||
siblingExpiry = sibling.expiry_date;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({
|
||||
...existing,
|
||||
no_sku: existing.no_sku || inferredSku,
|
||||
nama_item: existing.nama_item || inferredNamaItem,
|
||||
expiry_date: existing.expiry_date || siblingExpiry
|
||||
});
|
||||
}
|
||||
|
||||
// Return empty default state if not found, with inferred metadata
|
||||
return NextResponse.json({
|
||||
filename: safeFilename,
|
||||
no_sku: "",
|
||||
nama_item: "",
|
||||
expiry_date: "",
|
||||
no_sku: inferredSku,
|
||||
nama_item: inferredNamaItem,
|
||||
expiry_date: siblingExpiry,
|
||||
top1_confidence: null,
|
||||
notes: "",
|
||||
saved_at: ""
|
||||
@@ -107,13 +168,28 @@ export async function GET(req: NextRequest) {
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { filename } = body;
|
||||
const { filename, image } = body;
|
||||
|
||||
if (!filename) {
|
||||
return errorResponse(400, "Filename is required in request body");
|
||||
let safeFilename = sanitizeFilename(filename || "unknown.jpg");
|
||||
|
||||
if (image && image.startsWith("data:image/")) {
|
||||
const base64Data = image.replace(/^data:image\/\w+;base64,/, "");
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const hash = crypto.createHash("md5").update(buffer).digest("hex");
|
||||
const ext = image.match(/^data:image\/(\w+);base64,/)?.[1] || "jpg";
|
||||
safeFilename = `${hash}.${ext}`;
|
||||
|
||||
const saveDir = path.join(process.cwd(), "..", "sources", "product-test-images");
|
||||
if (!fs.existsSync(saveDir)) {
|
||||
fs.mkdirSync(saveDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(path.join(saveDir, safeFilename), buffer);
|
||||
}
|
||||
|
||||
if (!safeFilename || safeFilename === "unknown.jpg") {
|
||||
return errorResponse(400, "Filename or valid image is required in request body");
|
||||
}
|
||||
|
||||
const safeFilename = sanitizeFilename(filename);
|
||||
const labels = readLabels();
|
||||
const index = labels.findIndex((l) => l.filename === safeFilename);
|
||||
|
||||
@@ -142,3 +218,21 @@ export async function POST(req: NextRequest) {
|
||||
return errorResponse(500, message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const filename = searchParams.get("filename");
|
||||
if (!filename) return errorResponse(400, "Filename parameter is required");
|
||||
|
||||
const safeFilename = sanitizeFilename(filename);
|
||||
const labels = readLabels();
|
||||
const filtered = labels.filter((l) => l.filename !== safeFilename);
|
||||
|
||||
writeLabels(filtered);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Failed to delete label";
|
||||
return errorResponse(500, message);
|
||||
}
|
||||
}
|
||||
@@ -433,8 +433,8 @@ export async function POST(req: NextRequest) {
|
||||
};
|
||||
|
||||
const insertDocRes = await query(`
|
||||
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs, kode_toko)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (filename) DO UPDATE
|
||||
SET upload_time = EXCLUDED.upload_time,
|
||||
size = EXCLUDED.size,
|
||||
@@ -443,7 +443,8 @@ export async function POST(req: NextRequest) {
|
||||
layout_parsing_result = EXCLUDED.layout_parsing_result,
|
||||
is_sample = EXCLUDED.is_sample,
|
||||
file_hash = EXCLUDED.file_hash,
|
||||
processing_logs = EXCLUDED.processing_logs
|
||||
processing_logs = EXCLUDED.processing_logs,
|
||||
kode_toko = COALESCE(EXCLUDED.kode_toko, documents.kode_toko)
|
||||
RETURNING id
|
||||
`, [
|
||||
safeFile,
|
||||
@@ -454,7 +455,8 @@ export async function POST(req: NextRequest) {
|
||||
JSON.stringify(pipelineResult),
|
||||
isSample,
|
||||
fileHash,
|
||||
JSON.stringify(logsPayload)
|
||||
JSON.stringify(logsPayload),
|
||||
kodeToko || null
|
||||
]);
|
||||
|
||||
const docId = insertDocRes.rows[0].id;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { errorResponse } from "@/utils/api-error";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const filename = req.nextUrl.searchParams.get("filename");
|
||||
const dirPath = path.join(process.cwd(), "..", "sources", "product-test-images");
|
||||
|
||||
// File serving mode
|
||||
if (filename) {
|
||||
const safeFile = path.basename(filename);
|
||||
const filePath = path.join(dirPath, safeFile);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return errorResponse(404, "File not found");
|
||||
}
|
||||
|
||||
const ext = path.extname(safeFile).toLowerCase();
|
||||
let contentType = "application/octet-stream";
|
||||
if (ext === ".jpg" || ext === ".jpeg") contentType = "image/jpeg";
|
||||
else if (ext === ".png") contentType = "image/png";
|
||||
else if (ext === ".webp") contentType = "image/webp";
|
||||
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
return new Response(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// List mode
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return NextResponse.json({ files: [] });
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(dirPath).filter((file) => {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return [".jpg", ".jpeg", ".png", ".webp"].includes(ext);
|
||||
});
|
||||
|
||||
files.sort();
|
||||
return NextResponse.json({ files });
|
||||
} catch (error: unknown) {
|
||||
console.error("Error in product-images API:", error);
|
||||
const message = error instanceof Error ? error.message : "Internal server error";
|
||||
return errorResponse(500, message);
|
||||
}
|
||||
}
|
||||
@@ -27,22 +27,42 @@ export async function POST(req: NextRequest) {
|
||||
// carries that assignment so store name/address never need OCR
|
||||
// detection later; whichever account uploads, its own store is used.
|
||||
const accountRes = await query(
|
||||
"SELECT id, username, kode_toko, password FROM accounts WHERE username = $1",
|
||||
`SELECT a.id, a.username, a.password, a.role, a.is_active,
|
||||
s.kode_toko, s.nama_toko, s.alamat
|
||||
FROM accounts a
|
||||
LEFT JOIN store_master s ON a.kode_toko = s.kode_toko
|
||||
WHERE a.username = $1`,
|
||||
[username]
|
||||
);
|
||||
|
||||
if (accountRes.rowCount && accountRes.rowCount > 0 && bcrypt.compareSync(password, accountRes.rows[0].password)) {
|
||||
const account = accountRes.rows[0];
|
||||
|
||||
if (!account.is_active) {
|
||||
return errorResponse(401, "Account is disabled", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const token = signAccountToken({
|
||||
accountId: account.id,
|
||||
username: account.username,
|
||||
kodeToko: account.kode_toko
|
||||
kodeToko: account.kode_toko,
|
||||
role: account.role
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
status: "success",
|
||||
message: "Login successful",
|
||||
data: { token }
|
||||
data: {
|
||||
token,
|
||||
profile: {
|
||||
username: account.username,
|
||||
role: account.role,
|
||||
is_active: account.is_active,
|
||||
kodeToko: account.kode_toko,
|
||||
namaToko: account.nama_toko,
|
||||
alamat: account.alamat
|
||||
}
|
||||
}
|
||||
}, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { errorResponse } from "@/utils/api-error";
|
||||
import { getAccountFromAuthHeader } from "@/utils/auth";
|
||||
import { query } from "../../../../../db";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const tokenPayload = getAccountFromAuthHeader(authHeader);
|
||||
|
||||
if (!tokenPayload) {
|
||||
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const accountRes = await query(
|
||||
`SELECT a.id, a.username, a.role, a.is_active,
|
||||
s.kode_toko, s.nama_toko, s.alamat
|
||||
FROM accounts a
|
||||
LEFT JOIN store_master s ON a.kode_toko = s.kode_toko
|
||||
WHERE a.id = $1`,
|
||||
[tokenPayload.accountId]
|
||||
);
|
||||
|
||||
if (accountRes.rowCount && accountRes.rowCount > 0) {
|
||||
const account = accountRes.rows[0];
|
||||
|
||||
if (!account.is_active) {
|
||||
return errorResponse(401, "Account is disabled", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
// We extract the token exactly as passed in to echo it back in the same shape as login
|
||||
const token = authHeader?.slice("Bearer ".length).trim();
|
||||
|
||||
return NextResponse.json({
|
||||
status: "success",
|
||||
message: "Profile retrieved successfully",
|
||||
data: {
|
||||
token,
|
||||
profile: {
|
||||
username: account.username,
|
||||
role: account.role,
|
||||
is_active: account.is_active,
|
||||
kodeToko: account.kode_toko,
|
||||
namaToko: account.nama_toko,
|
||||
alamat: account.alamat
|
||||
}
|
||||
}
|
||||
}, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
return errorResponse(401, "Account not found", { headers: corsHeaders });
|
||||
} catch (error: unknown) {
|
||||
console.error("Error in auth/me API route:", error);
|
||||
const message = error instanceof Error ? error.message : "Internal server error";
|
||||
return errorResponse(500, message, { headers: corsHeaders });
|
||||
}
|
||||
}
|
||||
@@ -32,13 +32,17 @@ export async function PUT(
|
||||
}
|
||||
|
||||
// Check if document exists
|
||||
const checkRes = await query("SELECT id, filename, upload_time FROM documents WHERE id = $1", [docId]);
|
||||
const checkRes = await query("SELECT id, filename, upload_time, kode_toko FROM documents WHERE id = $1", [docId]);
|
||||
if (!checkRes.rowCount || checkRes.rowCount === 0) {
|
||||
return errorResponse(404, "Document not found", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const doc = checkRes.rows[0];
|
||||
|
||||
if (account.role !== 'admin' && doc.kode_toko !== account.kodeToko) {
|
||||
return errorResponse(403, "Forbidden: You do not have permission to modify this document", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
tanggal,
|
||||
|
||||
@@ -21,12 +21,21 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Retrieve all custom-uploaded documents
|
||||
const docRes = await query(`
|
||||
let docsQuery = `
|
||||
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude
|
||||
FROM documents
|
||||
WHERE is_sample = false AND parsed = true
|
||||
ORDER BY upload_time DESC
|
||||
`);
|
||||
`;
|
||||
const queryParams: any[] = [];
|
||||
|
||||
if (account.role !== 'admin') {
|
||||
docsQuery += ` AND kode_toko = $1`;
|
||||
queryParams.push(account.kodeToko);
|
||||
}
|
||||
|
||||
docsQuery += ` ORDER BY upload_time DESC`;
|
||||
|
||||
const docRes = await query(docsQuery, queryParams);
|
||||
|
||||
const documents = docRes.rows;
|
||||
const mappedList = [];
|
||||
|
||||
@@ -58,10 +58,13 @@ export async function POST(req: NextRequest) {
|
||||
const latitude = latVal ? parseFloat(latVal.toString()) : null;
|
||||
const longitude = lngVal ? parseFloat(lngVal.toString()) : null;
|
||||
|
||||
const existing = await query(
|
||||
"SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 ORDER BY upload_time ASC LIMIT 1",
|
||||
[fileHash]
|
||||
);
|
||||
// Basic dedup
|
||||
const dedupQuery = account?.kodeToko
|
||||
? "SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 AND kode_toko = $2 ORDER BY upload_time ASC LIMIT 1"
|
||||
: "SELECT id, latitude, longitude, upload_time FROM documents WHERE file_hash = $1 AND kode_toko IS NULL ORDER BY upload_time ASC LIMIT 1";
|
||||
const dedupParams = account?.kodeToko ? [fileHash, account.kodeToko] : [fileHash];
|
||||
|
||||
const existing = await query(dedupQuery, dedupParams);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
const existingDoc = existing.rows[0];
|
||||
@@ -98,8 +101,8 @@ export async function POST(req: NextRequest) {
|
||||
let finalFilename = filename;
|
||||
|
||||
const insertRes = await query(`
|
||||
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
INSERT INTO documents (filename, upload_time, size, parsed, is_sample, file_hash, latitude, longitude, kode_toko)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`, [
|
||||
filename,
|
||||
@@ -109,7 +112,8 @@ export async function POST(req: NextRequest) {
|
||||
false,
|
||||
fileHash,
|
||||
latitude,
|
||||
longitude
|
||||
longitude,
|
||||
account?.kodeToko || null
|
||||
]);
|
||||
docId = insertRes.rows[0].id;
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "../../../../db";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
||||
};
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
let dbHealthy = false;
|
||||
let pipelineHealthy = false;
|
||||
|
||||
// Check Database
|
||||
try {
|
||||
const res = await query("SELECT 1 as healthy");
|
||||
if (res.rowCount && res.rows[0].healthy === 1) {
|
||||
dbHealthy = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Health check - DB ping failed:", err);
|
||||
}
|
||||
|
||||
// Check Pipeline API
|
||||
try {
|
||||
const pipelineUrl = process.env.PIPELINE_URL;
|
||||
// e.g. http://paddleocr-pipeline-api:8090/layout-parsing
|
||||
if (pipelineUrl) {
|
||||
const healthUrl = new URL("/", pipelineUrl).toString();
|
||||
const response = await fetch(healthUrl, { method: "GET", signal: AbortSignal.timeout(3000) });
|
||||
// As long as the server responds (even with 404 or 405), it is running.
|
||||
if (response.status) {
|
||||
pipelineHealthy = true;
|
||||
}
|
||||
} else {
|
||||
console.warn("Health check - PIPELINE_URL not configured in environment");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Health check - Pipeline ping failed:", err);
|
||||
}
|
||||
|
||||
const isHealthy = dbHealthy && pipelineHealthy;
|
||||
|
||||
return NextResponse.json({
|
||||
status: isHealthy ? "ok" : "error",
|
||||
db: dbHealthy,
|
||||
pipeline: pipelineHealthy,
|
||||
}, {
|
||||
status: isHealthy ? 200 : 503,
|
||||
headers: corsHeaders
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/db";
|
||||
import { errorResponse } from "@/utils/api-error";
|
||||
import { getAccountFromAuthHeader } from "@/utils/auth";
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ kode: string }> }
|
||||
) {
|
||||
try {
|
||||
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
|
||||
if (!account || account.role !== 'admin') {
|
||||
return errorResponse(403, "Forbidden: Admin access required");
|
||||
}
|
||||
|
||||
const { kode } = await context.params;
|
||||
const body = await req.json();
|
||||
const {
|
||||
nama_item,
|
||||
jenis_outer,
|
||||
standar_jumlah
|
||||
} = body;
|
||||
|
||||
const res = await query(
|
||||
`UPDATE sku_master
|
||||
SET nama_item = $1, jenis_outer = $2, standar_jumlah = $3
|
||||
WHERE no_sku = $4 RETURNING *`,
|
||||
[
|
||||
nama_item,
|
||||
jenis_outer || '',
|
||||
String(standar_jumlah || '1'),
|
||||
kode
|
||||
]
|
||||
);
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
return errorResponse(404, "SKU not found");
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "success", data: res.rows[0] });
|
||||
} catch (err: any) {
|
||||
return errorResponse(500, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ kode: string }> }
|
||||
) {
|
||||
try {
|
||||
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
|
||||
if (!account || account.role !== 'admin') {
|
||||
return errorResponse(403, "Forbidden: Admin access required");
|
||||
}
|
||||
|
||||
const { kode } = await context.params;
|
||||
|
||||
const res = await query("DELETE FROM sku_master WHERE no_sku = $1 RETURNING *", [kode]);
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
return errorResponse(404, "SKU not found");
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "success", message: "SKU deleted successfully" });
|
||||
} catch (err: any) {
|
||||
if (err.code === '23503') { // foreign key violation
|
||||
return errorResponse(409, "Cannot delete SKU because it is referenced in documents");
|
||||
}
|
||||
return errorResponse(500, err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/db";
|
||||
import { errorResponse } from "@/utils/api-error";
|
||||
import { getAccountFromAuthHeader } from "@/utils/auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
|
||||
if (!account || account.role !== 'admin') {
|
||||
return errorResponse(403, "Forbidden: Admin access required");
|
||||
}
|
||||
|
||||
const res = await query(`
|
||||
SELECT no_sku, nama_item, standar_jumlah, berat_kemasan, isi_outer_kg, isi_outer_pac, jenis_outer
|
||||
FROM sku_master
|
||||
ORDER BY no_sku ASC
|
||||
`);
|
||||
return NextResponse.json({ status: "success", data: res.rows });
|
||||
} catch (err: any) {
|
||||
return errorResponse(500, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
|
||||
if (!account || account.role !== 'admin') {
|
||||
return errorResponse(403, "Forbidden: Admin access required");
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
kode_item,
|
||||
no_sku,
|
||||
nama_item,
|
||||
jenis_outer,
|
||||
standar_jumlah
|
||||
} = body;
|
||||
|
||||
const skuCode = no_sku || kode_item;
|
||||
|
||||
if (!skuCode || !nama_item) {
|
||||
return errorResponse(400, "no_sku and nama_item are required");
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO sku_master
|
||||
(no_sku, nama_item, jenis_outer, standar_jumlah)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[
|
||||
skuCode,
|
||||
nama_item,
|
||||
jenis_outer || '',
|
||||
String(standar_jumlah || '1')
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json({ status: "success", message: "SKU created successfully" });
|
||||
} catch (err: any) {
|
||||
if (err.code === '23505') { // unique violation
|
||||
return errorResponse(409, "SKU with this kode_item already exists");
|
||||
}
|
||||
return errorResponse(500, err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query, withTransaction } from "@/db";
|
||||
import { errorResponse } from "@/utils/api-error";
|
||||
import { getAccountFromAuthHeader } from "@/utils/auth";
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ kode: string }> }
|
||||
) {
|
||||
try {
|
||||
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
|
||||
if (!account || account.role !== 'admin') {
|
||||
return errorResponse(403, "Forbidden: Admin access required");
|
||||
}
|
||||
|
||||
const { kode } = await context.params;
|
||||
const body = await req.json();
|
||||
const { nama_toko, alamat } = body;
|
||||
|
||||
const res = await query(
|
||||
"UPDATE store_master SET nama_toko = $1, alamat = $2 WHERE kode_toko = $3 RETURNING *",
|
||||
[nama_toko, alamat || '', kode]
|
||||
);
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
return errorResponse(404, "Store not found");
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "success", data: res.rows[0] });
|
||||
} catch (err: any) {
|
||||
return errorResponse(500, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ kode: string }> }
|
||||
) {
|
||||
try {
|
||||
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
|
||||
if (!account || account.role !== 'admin') {
|
||||
return errorResponse(403, "Forbidden: Admin access required");
|
||||
}
|
||||
|
||||
const { kode } = await context.params;
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
// Delete associated account first due to FK account -> store_master
|
||||
await client.query("DELETE FROM accounts WHERE kode_toko = $1", [kode]);
|
||||
|
||||
const res = await client.query("DELETE FROM store_master WHERE kode_toko = $1 RETURNING *", [kode]);
|
||||
|
||||
if (res.rowCount === 0) {
|
||||
throw new Error("Store not found");
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ status: "success", message: "Store and associated account deleted successfully" });
|
||||
} catch (err: any) {
|
||||
if (err.code === '23503') { // foreign key violation (e.g. documents exist)
|
||||
return errorResponse(409, "Cannot delete store because it has associated documents");
|
||||
}
|
||||
if (err.message === "Store not found") {
|
||||
return errorResponse(404, err.message);
|
||||
}
|
||||
return errorResponse(500, err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query, withTransaction } from "@/db";
|
||||
import { errorResponse } from "@/utils/api-error";
|
||||
import { getAccountFromAuthHeader } from "@/utils/auth";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
console.log("Auth Header in GET:", authHeader);
|
||||
const account = getAccountFromAuthHeader(authHeader);
|
||||
console.log("Decoded Account:", account);
|
||||
if (!account || account.role !== 'admin') {
|
||||
return errorResponse(403, "Forbidden: Admin access required");
|
||||
}
|
||||
|
||||
const res = await query("SELECT kode_toko, nama_toko, alamat FROM store_master ORDER BY kode_toko ASC");
|
||||
return NextResponse.json({ status: "success", data: res.rows });
|
||||
} catch (err: any) {
|
||||
return errorResponse(500, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
|
||||
if (!account || account.role !== 'admin') {
|
||||
return errorResponse(403, "Forbidden: Admin access required");
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { kode_toko, nama_toko, alamat } = body;
|
||||
|
||||
if (!kode_toko || !nama_toko) {
|
||||
return errorResponse(400, "kode_toko and nama_toko are required");
|
||||
}
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
// 1. Insert store
|
||||
await client.query(
|
||||
"INSERT INTO store_master (kode_toko, nama_toko, alamat) VALUES ($1, $2, $3)",
|
||||
[kode_toko, nama_toko, alamat || '']
|
||||
);
|
||||
|
||||
// 2. Hash default password
|
||||
const hashedPassword = await bcrypt.hash('123', 10);
|
||||
|
||||
// 3. Create default account
|
||||
await client.query(
|
||||
`INSERT INTO accounts (username, password, role, is_active, kode_toko)
|
||||
VALUES ($1, $2, 'store', true, $3)`,
|
||||
[kode_toko, hashedPassword, kode_toko]
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json({ status: "success", message: "Store and account created successfully" });
|
||||
} catch (err: any) {
|
||||
if (err.code === '23505') { // unique violation
|
||||
return errorResponse(409, "Store with this kode_toko already exists");
|
||||
}
|
||||
return errorResponse(500, err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Sidebar } from "@/components/manual-label-scan/Sidebar";
|
||||
import { Editor, ScanLabelFormData, AiPredictedData } from "@/components/manual-label-scan/Editor";
|
||||
import { ImageViewer } from "@/components/manual-label-scan/ImageViewer";
|
||||
import { getErrorMessage } from "@/utils/client-error";
|
||||
|
||||
export default function ManualLabelScanPage() {
|
||||
const [files, setFiles] = useState<{ url: string; filename: string }[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||
|
||||
const [formData, setFormData] = useState<ScanLabelFormData>({
|
||||
filename: "",
|
||||
no_sku: "",
|
||||
nama_item: "",
|
||||
expiry_date: "",
|
||||
notes: ""
|
||||
});
|
||||
const [aiPredicted, setAiPredicted] = useState<AiPredictedData | null>(null);
|
||||
|
||||
const [skuList, setSkuList] = useState<Array<{ no_sku: string; nama_item: string }>>([]);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [savingGT, setSavingGT] = useState(false);
|
||||
|
||||
const [toast, setToast] = useState({ message: "", show: false, isError: false });
|
||||
const showToast = (message: string, isError = false) => {
|
||||
setToast({ message, show: true, isError });
|
||||
setTimeout(() => setToast(p => ({ ...p, show: false })), 2500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllData = async () => {
|
||||
try {
|
||||
// Fetch Skus
|
||||
const skuRes = await fetch("/api/skus");
|
||||
if (skuRes.ok) {
|
||||
const skuData = await skuRes.json();
|
||||
setSkuList(skuData.skus || []);
|
||||
}
|
||||
|
||||
// Fetch Training Images
|
||||
const pfmRes = await fetch("/api/produk-pfm");
|
||||
let trainingFiles: { url: string; filename: string }[] = [];
|
||||
if (pfmRes.ok) {
|
||||
const pfmData = await pfmRes.json();
|
||||
trainingFiles = (pfmData.products || []).flatMap((p: any) =>
|
||||
p.images.map((url: string) => ({
|
||||
url,
|
||||
filename: url.replace(/^\/produk-pfm\/foto-kemasan-v2\//, "")
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch Test Images
|
||||
const testRes = await fetch("/api/product-images");
|
||||
let testFiles: { url: string; filename: string }[] = [];
|
||||
if (testRes.ok) {
|
||||
const testData = await testRes.json();
|
||||
testFiles = (testData.files || []).map((f: string) => ({
|
||||
url: `/api/product-images?filename=${encodeURIComponent(f)}`,
|
||||
filename: f
|
||||
}));
|
||||
}
|
||||
|
||||
const combined = [...testFiles, ...trainingFiles];
|
||||
setFiles(combined);
|
||||
if (combined.length > 0) setCurrentIndex(0);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error initializing page", err);
|
||||
showToast("Error loading dataset files", true);
|
||||
}
|
||||
};
|
||||
fetchAllData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIndex < 0 || currentIndex >= files.length) return;
|
||||
const file = files[currentIndex];
|
||||
|
||||
const loadLabel = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/manual-label-scan?filename=${encodeURIComponent(file.filename)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setFormData({
|
||||
filename: data.filename || file.filename,
|
||||
no_sku: data.no_sku || "",
|
||||
nama_item: data.nama_item || "",
|
||||
expiry_date: data.expiry_date || "",
|
||||
notes: data.notes || ""
|
||||
});
|
||||
setAiPredicted(null); // Reset AI predictions on new file load
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching label", err);
|
||||
}
|
||||
};
|
||||
loadLabel();
|
||||
}, [currentIndex, files]);
|
||||
|
||||
// Handle Ctrl+S keyboard shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [formData]);
|
||||
|
||||
const handleChange = (field: keyof ScanLabelFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleScanWithAi = async () => {
|
||||
if (currentIndex < 0) return;
|
||||
const currentFile = files[currentIndex];
|
||||
setIsScanning(true);
|
||||
try {
|
||||
// Fetch image as base64
|
||||
const imgRes = await fetch(currentFile.url);
|
||||
const blob = await imgRes.blob();
|
||||
const base64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
const scanRes = await fetch("/api/scan-pfm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image: base64 })
|
||||
});
|
||||
|
||||
if (!scanRes.ok) throw new Error("Pipeline API error");
|
||||
const scanData = await scanRes.json();
|
||||
|
||||
setAiPredicted({
|
||||
no_sku: scanData.classification?.top1_name ? skuList.find(s => s.nama_item === scanData.classification.top1_name)?.no_sku : undefined,
|
||||
nama_item: scanData.classification?.top1_name,
|
||||
expiry_date: scanData.ocr?.extracted_expired_date
|
||||
});
|
||||
|
||||
showToast("AI Scan complete!");
|
||||
} catch (err) {
|
||||
showToast(getErrorMessage(err, undefined, "AI Scan failed"), true);
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSavingGT(true);
|
||||
try {
|
||||
const res = await fetch("/api/manual-label-scan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast("Ground truth saved successfully!");
|
||||
if (currentIndex < files.length - 1) {
|
||||
setCurrentIndex(prev => prev + 1);
|
||||
} else {
|
||||
showToast("All images completed!");
|
||||
}
|
||||
} else {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
showToast(getErrorMessage(null, errData, "Save failed"), true);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(getErrorMessage(err, undefined, "Save failed"), true);
|
||||
} finally {
|
||||
setSavingGT(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex flex-col bg-slate-950 font-sans overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-14 border-b border-slate-800 bg-slate-900/80 backdrop-blur-md flex items-center justify-between px-6 shrink-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-teal-500 to-cyan-500 flex items-center justify-center font-bold text-white text-xs shadow-md">
|
||||
SP
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-100">Product Scan Annotation</span>
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
<a href="/scan-pfm" className="text-slate-400 hover:text-slate-100 transition">← Back to Scanner</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Body */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Sidebar
|
||||
files={files.map(f => f.filename)}
|
||||
currentIndex={currentIndex}
|
||||
onSelect={setCurrentIndex}
|
||||
/>
|
||||
<ImageViewer src={currentIndex >= 0 ? files[currentIndex].url : null} />
|
||||
<Editor
|
||||
formData={formData}
|
||||
aiPredicted={aiPredicted}
|
||||
skuList={skuList}
|
||||
isScanning={isScanning}
|
||||
onScanWithAi={handleScanWithAi}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
savingGT={savingGT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
<div className={`fixed bottom-6 left-1/2 -translate-x-1/2 px-5 py-3 rounded-lg flex items-center gap-2.5 shadow-2xl font-medium z-[100] transition duration-300 ${toast.show ? "translate-y-0 opacity-100 scale-100" : "translate-y-12 opacity-0 scale-95 pointer-events-none"} ${toast.isError ? "bg-rose-600 text-white" : "bg-emerald-600 text-white"}`}>
|
||||
<span>{toast.isError ? "❌" : "✅"}</span>
|
||||
<span className="text-sm">{toast.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export default function ScanPfmPage() {
|
||||
const [editedProductName, setEditedProductName] = useState<string>("");
|
||||
const [isEditingSku, setIsEditingSku] = useState<boolean>(false);
|
||||
const [editedSku, setEditedSku] = useState<string>("");
|
||||
const [gtNotes, setGtNotes] = useState<string>("");
|
||||
|
||||
const [skuList, setSkuList] = useState<Array<{ no_sku: string; nama_item: string }>>([]);
|
||||
|
||||
@@ -346,7 +347,8 @@ export default function ScanPfmPage() {
|
||||
nama_item: scanResult.classification?.top1_name || "",
|
||||
expiry_date: normalizeDateString(scanResult.ocr?.extracted_expired_date || ""),
|
||||
top1_confidence: scanResult.classification?.top1_confidence ?? null,
|
||||
notes: ""
|
||||
notes: gtNotes,
|
||||
image: selectedImage?.startsWith("data:image/") ? selectedImage : undefined
|
||||
};
|
||||
|
||||
const res = await fetch("/api/manual-label-scan", {
|
||||
@@ -984,6 +986,17 @@ export default function ScanPfmPage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<span className="text-[9px] text-slate-500 block mb-1 uppercase font-semibold">Notes (Optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={gtNotes}
|
||||
onChange={(e) => setGtNotes(e.target.value)}
|
||||
placeholder="e.g. blurry, wrong classification, etc."
|
||||
className="bg-slate-900 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500 w-full transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
id="btn-save-ground-truth"
|
||||
onClick={handleSaveGroundTruth}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from "react";
|
||||
|
||||
export interface ScanLabelFormData {
|
||||
filename: string;
|
||||
no_sku: string;
|
||||
nama_item: string;
|
||||
expiry_date: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface AiPredictedData {
|
||||
no_sku?: string;
|
||||
nama_item?: string;
|
||||
expiry_date?: string;
|
||||
}
|
||||
|
||||
interface EditorProps {
|
||||
formData: ScanLabelFormData;
|
||||
aiPredicted: AiPredictedData | null;
|
||||
skuList: Array<{ no_sku: string; nama_item: string }>;
|
||||
isScanning: boolean;
|
||||
onScanWithAi: () => void;
|
||||
onChange: (field: keyof ScanLabelFormData, value: string) => void;
|
||||
onSave: () => void;
|
||||
savingGT: boolean;
|
||||
}
|
||||
|
||||
export function Editor({
|
||||
formData,
|
||||
aiPredicted,
|
||||
skuList,
|
||||
isScanning,
|
||||
onScanWithAi,
|
||||
onChange,
|
||||
onSave,
|
||||
savingGT
|
||||
}: EditorProps) {
|
||||
// Autofill item name based on SKU if available
|
||||
const handleSkuChange = (value: string) => {
|
||||
onChange("no_sku", value);
|
||||
const matched = skuList.find(s => s.no_sku === value);
|
||||
if (matched && !formData.nama_item) {
|
||||
onChange("nama_item", matched.nama_item);
|
||||
}
|
||||
};
|
||||
|
||||
const AiNote = ({ current, aiValue }: { current: string; aiValue: string | undefined }) => {
|
||||
if (aiValue === undefined) return null;
|
||||
const differs = (current || "").trim() !== (aiValue || "").trim();
|
||||
return (
|
||||
<div className={`text-[10px] leading-tight mt-1 ${differs ? "text-amber-500" : "text-slate-500"}`}>
|
||||
AI: {aiValue || "(not detected)"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[400px] border-l border-slate-800 bg-slate-900/50 flex flex-col h-full shrink-0 overflow-y-auto">
|
||||
<div className="p-5 space-y-6">
|
||||
|
||||
{/* Header & AI Action */}
|
||||
<div className="flex flex-col gap-3 pb-4 border-b border-slate-800">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-100">Ground Truth Editor</h3>
|
||||
<p className="text-[11px] text-slate-500 truncate mt-0.5">{formData.filename || "No file selected"}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onScanWithAi}
|
||||
disabled={isScanning || !formData.filename}
|
||||
className="w-full bg-teal-600/20 text-teal-400 hover:bg-teal-600/30 disabled:opacity-50 border border-teal-500/30 rounded-lg py-2 text-xs font-semibold transition flex items-center justify-center gap-2"
|
||||
>
|
||||
{isScanning ? "Scanning with Pipeline..." : "Scan with AI 🤖"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">SKU</label>
|
||||
<input
|
||||
type="text"
|
||||
list="skuOptions"
|
||||
value={formData.no_sku}
|
||||
onChange={(e) => handleSkuChange(e.target.value)}
|
||||
placeholder="e.g. 12010119"
|
||||
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
/>
|
||||
<datalist id="skuOptions">
|
||||
{skuList.map((s) => (
|
||||
<option key={s.no_sku} value={s.no_sku}>
|
||||
{s.nama_item}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<AiNote current={formData.no_sku} aiValue={aiPredicted?.no_sku} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Product Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.nama_item}
|
||||
onChange={(e) => onChange("nama_item", e.target.value)}
|
||||
placeholder="e.g. FIESTA NUGGET 400 GR"
|
||||
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
/>
|
||||
<AiNote current={formData.nama_item} aiValue={aiPredicted?.nama_item} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Expiry Date</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.expiry_date}
|
||||
onChange={(e) => onChange("expiry_date", e.target.value)}
|
||||
placeholder="e.g. 05/11/2026"
|
||||
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors"
|
||||
/>
|
||||
<AiNote current={formData.expiry_date} aiValue={aiPredicted?.expiry_date} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Notes</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => onChange("notes", e.target.value)}
|
||||
placeholder="Optional notes..."
|
||||
rows={3}
|
||||
className="bg-slate-950 border border-slate-800 text-slate-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-emerald-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="mt-auto p-5 border-t border-slate-800 bg-slate-900">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={savingGT || !formData.filename}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl py-3 text-sm font-bold shadow-lg shadow-emerald-500/20 disabled:opacity-50 transition-all flex items-center justify-center"
|
||||
>
|
||||
{savingGT ? "Saving..." : "Save Ground Truth"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface ImageViewerProps {
|
||||
src: string | null;
|
||||
}
|
||||
|
||||
export function ImageViewer({ src }: ImageViewerProps) {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-slate-950">
|
||||
<span className="text-slate-600 text-sm">No image selected</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 relative flex flex-col bg-slate-950 overflow-hidden">
|
||||
{/* Controls */}
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2 bg-slate-900/80 backdrop-blur border border-slate-700 p-1.5 rounded-xl shadow-xl">
|
||||
<button
|
||||
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
|
||||
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-lg font-mono"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="text-xs font-medium text-slate-400 w-12 text-center">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
|
||||
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-lg font-mono"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="w-px h-5 bg-slate-700 mx-1" />
|
||||
<button
|
||||
onClick={() => setRotation((r) => r - 90)}
|
||||
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-sm"
|
||||
title="Rotate Left"
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRotation((r) => r + 90)}
|
||||
className="w-8 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-sm"
|
||||
title="Rotate Right"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
<div className="w-px h-5 bg-slate-700 mx-1" />
|
||||
<button
|
||||
onClick={() => { setScale(1); setRotation(0); }}
|
||||
className="px-3 h-8 rounded-lg hover:bg-slate-700 text-slate-300 flex items-center justify-center text-xs font-medium"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Viewport */}
|
||||
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
|
||||
{/* Using standard img for easy rotation/scaling without Next.js Image component strictness */}
|
||||
<img
|
||||
src={src}
|
||||
alt="Product Scan"
|
||||
style={{
|
||||
transform: `scale(${scale}) rotate(${rotation}deg)`,
|
||||
transition: "transform 0.2s ease-out",
|
||||
maxHeight: "80vh"
|
||||
}}
|
||||
className="shadow-2xl rounded-sm object-contain"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
|
||||
interface SidebarProps {
|
||||
files: string[];
|
||||
currentIndex: number;
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ files, currentIndex, onSelect }: SidebarProps) {
|
||||
return (
|
||||
<div className="w-64 border-r border-slate-800 bg-slate-900/50 flex flex-col h-full shrink-0">
|
||||
<div className="p-4 border-b border-slate-800">
|
||||
<h2 className="text-sm font-bold text-slate-100">Dataset Images</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">{files.length} files found</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{files.map((file, idx) => {
|
||||
const isSelected = idx === currentIndex;
|
||||
// Extract just the filename for display
|
||||
const display = file.split("/").pop() || file;
|
||||
return (
|
||||
<button
|
||||
key={file}
|
||||
onClick={() => onSelect(idx)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-xs truncate transition-colors ${
|
||||
isSelected
|
||||
? "bg-emerald-500/20 text-emerald-400 font-medium"
|
||||
: "text-slate-400 hover:bg-slate-800/50 hover:text-slate-200"
|
||||
}`}
|
||||
title={file}
|
||||
>
|
||||
{idx + 1}. {display}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{files.length === 0 && (
|
||||
<div className="text-center text-xs text-slate-500 mt-4">
|
||||
No images found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export async function initDb(pool: Pool) {
|
||||
layout_parsing_result JSONB,
|
||||
is_sample BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
file_hash VARCHAR(64),
|
||||
processing_logs JSONB
|
||||
processing_logs JSONB,
|
||||
kode_toko VARCHAR(255)
|
||||
);
|
||||
`);
|
||||
|
||||
@@ -29,6 +30,8 @@ export async function initDb(pool: Pool) {
|
||||
try {
|
||||
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64);");
|
||||
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_logs JSONB;");
|
||||
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS kode_toko VARCHAR(255);");
|
||||
await pool.query("CREATE INDEX IF NOT EXISTS idx_documents_file_hash ON documents(file_hash);");
|
||||
} catch (alterErr) {
|
||||
console.error("Failed to alter documents table for schema upgrade:", alterErr);
|
||||
}
|
||||
@@ -104,6 +107,42 @@ export async function initDb(pool: Pool) {
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed store_master from toko_aktif.json
|
||||
const sourcesDir = fs.existsSync("/sources/toko_aktif.json")
|
||||
? "/sources"
|
||||
: path.join(process.cwd(), "..", "sources");
|
||||
const tokoPath = path.join(sourcesDir, "toko_aktif.json");
|
||||
|
||||
if (fs.existsSync(tokoPath)) {
|
||||
console.log("Loading store_master from toko_aktif.json...");
|
||||
try {
|
||||
const tokoData = JSON.parse(fs.readFileSync(tokoPath, "utf-8"));
|
||||
for (const toko of tokoData) {
|
||||
if (toko.kodeToko && toko.namaToko && toko.alamat) {
|
||||
await pool.query(`
|
||||
INSERT INTO store_master (kode_toko, nama_toko, alamat)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (kode_toko) DO UPDATE SET
|
||||
nama_toko = EXCLUDED.nama_toko,
|
||||
alamat = EXCLUDED.alamat;
|
||||
`, [toko.kodeToko, toko.namaToko, toko.alamat]);
|
||||
}
|
||||
}
|
||||
console.log(`Loaded ${tokoData.length} stores into store_master.`);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse or load toko_aktif.json:", err);
|
||||
}
|
||||
} else {
|
||||
console.warn(`toko_aktif.json not found at ${tokoPath}`);
|
||||
}
|
||||
|
||||
// Ensure WH_JOFFICE exists for admin account foreign key
|
||||
await pool.query(`
|
||||
INSERT INTO store_master (kode_toko, nama_toko, alamat)
|
||||
VALUES ('WH_JOFFICE', 'Head Office', 'Jakarta')
|
||||
ON CONFLICT (kode_toko) DO NOTHING;
|
||||
`);
|
||||
|
||||
// Create accounts table - each account is assigned one store (kode_toko),
|
||||
// so store name/address never need to be OCR-detected: whichever account
|
||||
// uploads a document, that account's assigned store is used directly.
|
||||
@@ -113,16 +152,35 @@ export async function initDb(pool: Pool) {
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
kode_toko VARCHAR(255) REFERENCES store_master(kode_toko),
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'store',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
try {
|
||||
await pool.query("ALTER TABLE accounts ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'store';");
|
||||
await pool.query("ALTER TABLE accounts ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;");
|
||||
} catch (alterErr) {
|
||||
console.error("Failed to alter accounts table for schema upgrade:", alterErr);
|
||||
}
|
||||
|
||||
// Seed store accounts from store_master
|
||||
console.log("Seeding store accounts from store_master...");
|
||||
const defaultPasswordHash = bcrypt.hashSync("password", 10);
|
||||
await pool.query(`
|
||||
INSERT INTO accounts (username, password, kode_toko, role)
|
||||
SELECT kode_toko, $1, kode_toko, 'store' FROM store_master
|
||||
WHERE kode_toko != 'WH_JOFFICE'
|
||||
ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password;
|
||||
`, [defaultPasswordHash]);
|
||||
|
||||
// Seed the demo account, assigned to the head office store. Hashed here
|
||||
// (not as a SQL literal) since bcrypt can't run inside plain SQL.
|
||||
await pool.query(
|
||||
`INSERT INTO accounts (username, password, kode_toko)
|
||||
VALUES ('admin', $1, 'WH_JOFFICE')
|
||||
ON CONFLICT (username) DO NOTHING;`,
|
||||
`INSERT INTO accounts (username, password, kode_toko, role)
|
||||
VALUES ('admin', $1, 'WH_JOFFICE', 'admin')
|
||||
ON CONFLICT (username) DO UPDATE SET role = EXCLUDED.role, password = EXCLUDED.password;`,
|
||||
[bcrypt.hashSync("password", 10)]
|
||||
);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ActiveUploadLog {
|
||||
stage_1_output?: any;
|
||||
stage_2_output?: any;
|
||||
frontend_response?: any;
|
||||
pipeline_info?: any;
|
||||
}
|
||||
|
||||
// Store active logs in global context as a Map keyed by filename
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface AccountTokenPayload {
|
||||
accountId: number;
|
||||
username: string;
|
||||
kodeToko: string | null;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export function signAccountToken(payload: AccountTokenPayload): string {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Reference in new issue
Block a user