uat-seed-script

This commit is contained in:
phaichayon
2026-06-30 10:48:15 +07:00
parent e4573ac0c9
commit e12ae120b0
31 changed files with 1791 additions and 314 deletions

View File

@@ -1,10 +1,10 @@
{ {
"audit": "payload-parity", "audit": "payload-parity",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "CR2606-1124",
"previewHash": "c3cc553fc210355e7f54726b414e7030be0a64604f6a175499e0b3787aef31a1", "previewHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7",
"generationHash": "c3cc553fc210355e7f54726b414e7030be0a64604f6a175499e0b3787aef31a1", "generationHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7",
"snapshotHash": "c3cc553fc210355e7f54726b414e7030be0a64604f6a175499e0b3787aef31a1", "snapshotHash": "a55689ba304d5b0b2b9f6d94ca943b21233eb790de8f9b631a0ecb03ffa699d7",
"mismatches": [], "mismatches": [],
"comparableFieldCount": 31, "comparableFieldCount": 31,
"topicInputKeyCount": 6, "topicInputKeyCount": 6,

View File

@@ -3,8 +3,8 @@
"overallStatus": "PASS", "overallStatus": "PASS",
"results": [ "results": [
{ {
"templateName": "ONVALLA Quotation Standard", "templateName": "ALLA Demo Organization Quotation Standard",
"organizationId": "6bf90327-2ce9-4831-93bb-e8d026699ff2", "organizationId": "daf796fc-4588-5240-a27e-9bfef0739658",
"version": "1.0", "version": "1.0",
"duplicateFieldNames": [], "duplicateFieldNames": [],
"typoFields": [], "typoFields": [],
@@ -13,18 +13,6 @@
"invalidReservedMappings": [], "invalidReservedMappings": [],
"missingReservedFields": [], "missingReservedFields": [],
"status": "PASS" "status": "PASS"
},
{
"templateName": "ALLA Quotation Standard",
"organizationId": "d5c46139-9af4-421b-9395-d83620c62f9b",
"version": "2.1",
"duplicateFieldNames": [],
"typoFields": [],
"unmappedPlaceholders": [],
"deadMappings": [],
"invalidReservedMappings": [],
"missingReservedFields": [],
"status": "PASS"
} }
] ]
} }

View File

@@ -2,51 +2,51 @@
"audit": "runtime-payload", "audit": "runtime-payload",
"overallStatus": "PASS", "overallStatus": "PASS",
"staticLabelsStatus": "PASS", "staticLabelsStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "CR2606-1124",
"templateName": "ALLA Quotation Standard", "templateName": "ALLA Demo Organization Quotation Standard",
"templateVersion": "2.1", "templateVersion": "1.0",
"findingCount": 168, "findingCount": 168,
"findings": [ "findings": [
{ {
"field": "documentData.company.id", "field": "documentData.company.id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "d5c46139-9af4-421b-9395-d83620c62f9b", "value": "daf796fc-4588-5240-a27e-9bfef0739658",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.company.name", "field": "documentData.company.name",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "ALLA", "value": "ALLA Demo Organization",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.customer.id", "field": "documentData.customer.id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "56f1f9a3-6204-4ccc-af0f-5dbf866d5a56", "value": "96954c53-fd37-4d97-80e0-1809756a015d",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.customer.name", "field": "documentData.customer.name",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "บริษัท นอร์ธเทิร์น แมชชีนเนอรี่ ดีลเลอร์ จำกัด", "value": "มหาวิทยาลัยเทคโนโลยีพระจอมเกล้าพระนครเหนือ",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.customer.address", "field": "documentData.customer.address",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Mueang Chiang Mai, Chiang Mai", "value": "Bang Sue, Bangkok",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.customer.phone", "field": "documentData.customer.phone",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "02-400824", "value": "02-400827",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -60,14 +60,14 @@
"field": "documentData.contact.id", "field": "documentData.contact.id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "93843e06-65db-492f-9229-066e6dfdec5d", "value": "3fc91684-ebed-49a9-b50e-0fb6fdaae87f",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.contact.name", "field": "documentData.contact.name",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "คุณกิตติพงศ์ สุวรรณ", "value": "คุณวราภรณ์ เกษมศรี",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -81,56 +81,56 @@
"field": "documentData.contact.mobile", "field": "documentData.contact.mobile",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "0810000220", "value": "0810000250",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.contact.position", "field": "documentData.contact.position",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Plant Manager", "value": "Procurement Manager",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.quotation.id", "field": "documentData.quotation.id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "7dc0630b-e27d-4696-803a-d93f51bb9804", "value": "bb3ebe66-a0ed-4b48-b125-795b549fa798",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.quotation.code", "field": "documentData.quotation.code",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "QT2606-1122", "value": "CR2606-1124",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.quotation.projectName", "field": "documentData.quotation.projectName",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Dealer Stock Crane Package", "value": "Material Handling Lab Crane",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.quotation.projectLocation", "field": "documentData.quotation.projectLocation",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Chiang Mai Service Warehouse", "value": "Bang Sue Engineering Faculty",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.quotation.attention", "field": "documentData.quotation.attention",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "คุณกิตติพงศ์ สุวรรณ", "value": "คุณวราภรณ์ เกษมศรี",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.quotation.reference", "field": "documentData.quotation.reference",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "crm-uat-seed-v1:dealer-channel-approved", "value": "crm-uat-seed-v1:university-lab-crane-approved",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -151,14 +151,14 @@
"field": "documentData.quotation.subtotal", "field": "documentData.quotation.subtotal",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 4418000, "value": 3008000,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
"field": "documentData.quotation.totalAmount", "field": "documentData.quotation.totalAmount",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 4585442, "value": 3122003,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
@@ -172,7 +172,7 @@
"field": "documentData.items[0].id", "field": "documentData.items[0].id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "7b2a99ab-5106-4ab1-b611-cd63957aa38f", "value": "ab4102fc-7ad9-4338-aee2-b847c10dbf15",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -186,7 +186,7 @@
"field": "documentData.items[0].description", "field": "documentData.items[0].description",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Main equipment supply for Dealer Stock Crane Package", "value": "Main equipment supply for Material Handling Lab Crane",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -207,7 +207,7 @@
"field": "documentData.items[0].unitPrice", "field": "documentData.items[0].unitPrice",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 2521993, "value": 1717102,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
@@ -221,14 +221,14 @@
"field": "documentData.items[0].totalPrice", "field": "documentData.items[0].totalPrice",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 2521993, "value": 1717102,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
"field": "documentData.items[1].id", "field": "documentData.items[1].id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "cc058b8e-2ce1-4e66-a72c-6c40474cf962", "value": "9c320ba4-cf4b-4b3b-b72e-c588c8723384",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -242,7 +242,7 @@
"field": "documentData.items[1].description", "field": "documentData.items[1].description",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Installation and commissioning for Dealer Stock Crane Package", "value": "Installation and commissioning for Material Handling Lab Crane",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -263,7 +263,7 @@
"field": "documentData.items[1].unitPrice", "field": "documentData.items[1].unitPrice",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 1283924, "value": 874161,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
@@ -277,14 +277,14 @@
"field": "documentData.items[1].totalPrice", "field": "documentData.items[1].totalPrice",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 1283924, "value": 874161,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
"field": "documentData.items[2].id", "field": "documentData.items[2].id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "9436e5e6-6dc7-4fa6-bf8a-da2727992680", "value": "7f64760c-65d6-4b23-9921-4a33d43a2efc",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -298,7 +298,7 @@
"field": "documentData.items[2].description", "field": "documentData.items[2].description",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Operator training and documentation for Dealer Stock Crane Package", "value": "Operator training and documentation for Material Handling Lab Crane",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -319,7 +319,7 @@
"field": "documentData.items[2].unitPrice", "field": "documentData.items[2].unitPrice",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 458544, "value": 312200,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
@@ -333,14 +333,14 @@
"field": "documentData.items[2].totalPrice", "field": "documentData.items[2].totalPrice",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 458544, "value": 312200,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
"field": "documentData.items[3].id", "field": "documentData.items[3].id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "63d82897-1660-4f49-b0d1-fddb9712922f", "value": "4b5a4cef-3005-4253-9638-8c3cc9fade15",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -354,7 +354,7 @@
"field": "documentData.items[3].description", "field": "documentData.items[3].description",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Project management and testing for Dealer Stock Crane Package", "value": "Project management and testing for Material Handling Lab Crane",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -375,7 +375,7 @@
"field": "documentData.items[3].unitPrice", "field": "documentData.items[3].unitPrice",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 320981, "value": 218540,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
@@ -389,7 +389,7 @@
"field": "documentData.items[3].totalPrice", "field": "documentData.items[3].totalPrice",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": 320981, "value": 218540,
"message": "Scalar value is populated" "message": "Scalar value is populated"
}, },
{ {
@@ -403,21 +403,21 @@
"field": "documentData.quotationCustomers[0].id", "field": "documentData.quotationCustomers[0].id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "7a20818a-48a1-4eed-a779-ac5ec9d6159b", "value": "04ff8d80-b200-4c01-8081-24d548a2a576",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.quotationCustomers[0].customerId", "field": "documentData.quotationCustomers[0].customerId",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "56f1f9a3-6204-4ccc-af0f-5dbf866d5a56", "value": "96954c53-fd37-4d97-80e0-1809756a015d",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.quotationCustomers[0].role", "field": "documentData.quotationCustomers[0].role",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "09320e31-e8f9-42cd-a3ea-577210f29501", "value": "a5d93b6c-131b-4e9e-9240-ac94fe12f3d8",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -438,7 +438,7 @@
"field": "documentData.topics.all[0].id", "field": "documentData.topics.all[0].id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "ef076be2-fbef-444a-b7cf-cc2523e8b7dc", "value": "58c2d002-1c6f-40c4-b68a-c11ece1fff33",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -473,7 +473,7 @@
"field": "documentData.topics.all[0].items[0].content", "field": "documentData.topics.all[0].items[0].content",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Supply and execute dealer stock crane package according to approved drawing.", "value": "Supply and execute material handling lab crane according to approved drawing.",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -501,7 +501,7 @@
"field": "documentData.topics.all[1].id", "field": "documentData.topics.all[1].id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "14f41b98-f9d8-463b-9880-8e96d258a6ba", "value": "5a069f2d-dfb1-4ec0-9f51-3f9b657e7731",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -564,7 +564,7 @@
"field": "documentData.topics.all[2].id", "field": "documentData.topics.all[2].id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "54500c11-5f51-49d6-947e-db2f4d689959", "value": "d16b5a95-d811-4860-9670-596bef0c13df",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -634,7 +634,7 @@
"field": "documentData.topics.exclusion[0].id", "field": "documentData.topics.exclusion[0].id",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "14f41b98-f9d8-463b-9880-8e96d258a6ba", "value": "5a069f2d-dfb1-4ec0-9f51-3f9b657e7731",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -732,14 +732,14 @@
"field": "documentData.pdfme.quotation_date", "field": "documentData.pdfme.quotation_date",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Jun 3, 2026", "value": "Jun 15, 2026",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.pdfme.quotation_price", "field": "documentData.pdfme.quotation_price",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "THB 4,418,000.00", "value": "THB 3,008,000.00",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -802,49 +802,49 @@
"field": "documentData.signatures.preparedBy.name", "field": "documentData.signatures.preparedBy.name",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Super Admin", "value": "Sales Crane UAT",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.signatures.preparedBy.position", "field": "documentData.signatures.preparedBy.position",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Sales Manager", "value": "Sales Engineer",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.signatures.preparedBy.actedAt", "field": "documentData.signatures.preparedBy.actedAt",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "2026-06-03T09:00:00.000Z", "value": "2026-06-15T09:00:00.000Z",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.signatures.approvedBy.name", "field": "documentData.signatures.approvedBy.name",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Super Admin", "value": "-",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.signatures.approvedBy.position", "field": "documentData.signatures.approvedBy.position",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Sales Manager", "value": "-",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "documentData.signatures.approvedBy.actedAt", "field": "documentData.signatures.approvedBy.actedAt",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "WARNING",
"value": "2026-06-06T10:00:00.000Z", "value": null,
"message": "String is populated" "message": "Value is null"
}, },
{ {
"field": "documentData.signatures.authorizedBy.name", "field": "documentData.signatures.authorizedBy.name",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "Super Admin", "value": "Sales Manager UAT",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -858,28 +858,28 @@
"field": "documentData.signatures.authorizedBy.actedAt", "field": "documentData.signatures.authorizedBy.actedAt",
"source": "documentData", "source": "documentData",
"status": "PASS", "status": "PASS",
"value": "2026-06-07T09:00:00.000Z", "value": "2026-06-17T11:00:00.000Z",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.customer_name", "field": "templateInput.customer_name",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "บริษัท นอร์ธเทิร์น แมชชีนเนอรี่ ดีลเลอร์ จำกัด", "value": "มหาวิทยาลัยเทคโนโลยีพระจอมเกล้าพระนครเหนือ",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.customer_addr", "field": "templateInput.customer_addr",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Mueang Chiang Mai, Chiang Mai", "value": "Bang Sue, Bangkok",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.customer_tel", "field": "templateInput.customer_tel",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "02-400824", "value": "02-400827",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -893,35 +893,35 @@
"field": "templateInput.customer_att", "field": "templateInput.customer_att",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "คุณกิตติพงศ์ สุวรรณ", "value": "คุณวราภรณ์ เกษมศรี",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.project_name", "field": "templateInput.project_name",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Dealer Stock Crane Package", "value": "Material Handling Lab Crane",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.site_location", "field": "templateInput.site_location",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Chiang Mai Service Warehouse", "value": "Bang Sue Engineering Faculty",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.quotation_date_data", "field": "templateInput.quotation_date_data",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Jun 3, 2026", "value": "Jun 15, 2026",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.quotation_code_data", "field": "templateInput.quotation_code_data",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "QT2606-1122", "value": "CR2606-1124",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -935,7 +935,7 @@
"field": "templateInput.quotation_price", "field": "templateInput.quotation_price",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "THB 4,418,000.00", "value": "THB 3,008,000.00",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -984,35 +984,35 @@
"field": "templateInput.app1", "field": "templateInput.app1",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Super Admin", "value": "Sales Crane UAT",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.app1_position", "field": "templateInput.app1_position",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Sales Manager", "value": "Sales Engineer",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.app2", "field": "templateInput.app2",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Super Admin", "value": "-",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.app2_position", "field": "templateInput.app2_position",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Sales Manager", "value": "-",
"message": "String is populated" "message": "String is populated"
}, },
{ {
"field": "templateInput.app3", "field": "templateInput.app3",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Super Admin", "value": "Sales Manager UAT",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -1026,7 +1026,7 @@
"field": "templateInput.quotation_date", "field": "templateInput.quotation_date",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "Jun 3, 2026", "value": "Jun 15, 2026",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -1075,7 +1075,7 @@
"field": "templateInput.quotation_code", "field": "templateInput.quotation_code",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "QT2606-1122", "value": "CR2606-1124",
"message": "String is populated" "message": "String is populated"
}, },
{ {
@@ -1138,7 +1138,7 @@
"field": "templateInput.quotation_price_data[0][1]", "field": "templateInput.quotation_price_data[0][1]",
"source": "templateInput", "source": "templateInput",
"status": "PASS", "status": "PASS",
"value": "฿4,418,000.00", "value": "฿3,008,000.00",
"message": "Price table amount cell is formatted" "message": "Price table amount cell is formatted"
}, },
{ {

View File

@@ -1,14 +1,14 @@
{ {
"audit": "approved-snapshot-parity", "audit": "approved-snapshot-parity",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "CR2606-1124",
"approvedSnapshotAvailable": true, "approvedSnapshotAvailable": true,
"approvedTemplateVersionId": "86f99414-4656-44a9-9937-8457794dc710", "approvedTemplateVersionId": "850b13a2-c9ae-4992-89e7-a43f50c34378",
"computedTemplateVersionId": "86f99414-4656-44a9-9937-8457794dc710", "computedTemplateVersionId": "850b13a2-c9ae-4992-89e7-a43f50c34378",
"inconsistentTemplateKeys": [], "inconsistentTemplateKeys": [],
"computedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269", "computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
"storedSignatureHash": "72c05979b9a0b0666a06cce5b80dc67fe9c5493eced4366a4cf63785dc415269", "storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
"approvedArtifactReference": "artifact:ec300b87-3710-43a4-a63c-a72168fe3933", "approvedArtifactReference": null,
"refreshedFixtureSnapshot": true, "refreshedFixtureSnapshot": true,
"usedAuditFixture": false "usedAuditFixture": false
} }

View File

@@ -1,7 +1,7 @@
{ {
"generatedAt": "2026-06-30T01:35:49.157Z", "generatedAt": "2026-06-30T02:56:03.783Z",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "CR2606-1124",
"artifacts": { "artifacts": {
"templateVersionReport": "template-version-report.json", "templateVersionReport": "template-version-report.json",
"placeholderIntegrityReport": "placeholder-integrity-report.json", "placeholderIntegrityReport": "placeholder-integrity-report.json",

View File

@@ -1,8 +1,8 @@
# PDF Integrity Summary # PDF Integrity Summary
- Generated At: 2026-06-30T01:35:49.157Z - Generated At: 2026-06-30T02:56:03.783Z
- Overall Status: PASS - Overall Status: PASS
- Quotation Code: QT2606-1122 - Quotation Code: CR2606-1124
## Statuses ## Statuses

View File

@@ -1,8 +1,8 @@
{ {
"audit": "template-version-integrity", "audit": "template-version-integrity",
"overallStatus": "PASS", "overallStatus": "PASS",
"activeTemplateCount": 2, "activeTemplateCount": 1,
"quotationCode": "QT2606-1122", "quotationCode": "CR2606-1124",
"approvedTemplateVersionId": null, "approvedTemplateVersionId": null,
"approvedTemplateVersionIsActive": false, "approvedTemplateVersionIsActive": false,
"issues": [] "issues": []

View File

@@ -1,7 +1,7 @@
{ {
"audit": "topic-runtime", "audit": "topic-runtime",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "CR2606-1124",
"topicCount": 3, "topicCount": 3,
"topicInputKeys": [ "topicInputKeys": [
"topic_1_0", "topic_1_0",

View File

@@ -0,0 +1,88 @@
# Package Scripts Cleanup Audit
## Scope
- reviewed `package.json`
- reviewed `scripts/**`
- reviewed `src/db/seeds/**`
- reviewed `docs/operations/system-bootstrap.md`
- cross-checked historical references in `docs/implementation/**` and setup copy
## Decision Summary
| Script Name | Current Command | Status | Reason | Replacement Command |
| --- | --- | --- | --- | --- |
| `dev` | `next dev` | keep | core runtime entrypoint | - |
| `build` | `npm run verify:encoding && next build` | keep | build already guards encoding drift | - |
| `start` | `next start` | keep | production runtime entrypoint | - |
| `lint` | `oxlint` | keep | active lint entrypoint | - |
| `lint:fix` | `oxlint --fix && bun format` | keep | active fix workflow | - |
| `lint:strict` | `oxlint --deny-warnings` | keep | stricter CI-style lint path | - |
| `format` | `oxfmt --write .` | keep | active formatter entrypoint | - |
| `format:check` | `oxfmt --check .` | keep | active formatter verification | - |
| `prepare` | `husky` | keep | install hook bootstrap | - |
| `typecheck` | `tsc --noEmit` | add | required by task and docs verification flow | - |
| `gen` | `npm run verify:encoding && npx drizzle-kit generate` | remove | duplicate of `db:generate` with unclear shorthand | `npm run db:generate` |
| `db:generate` | `npx drizzle-kit generate` | keep | canonical drizzle generate command | - |
| `db:migrate` | `npx drizzle-kit migrate` | keep | canonical drizzle migrate command | - |
| `db:studio` | `npx drizzle-kit studio` | keep | canonical drizzle studio command | - |
| `db:reset` | `node --experimental-strip-types scripts/db/reset-database.ts` | keep | guarded destructive operation with env safety | - |
| `db:setup` | `npm run db:migrate && npm run seed:system` | keep | still referenced by setup flow and valid | - |
| `db:setup:uat` | `npm run db:setup && npm run seed:uat` | keep | valid setup composition for UAT | - |
| `db:fresh` | `npm run db:reset && npm run db:migrate && npm run seed:system` | keep | clean local bootstrap path | - |
| `db:fresh:uat` | `npm run db:fresh && npm run seed:uat` | keep | clean UAT bootstrap path | - |
| `migrate` | `npm run db:migrate` | remove | duplicate shorthand | `npm run db:migrate` |
| `studio` | `npm run db:studio` | remove | duplicate shorthand | `npm run db:studio` |
| `setup:db` | `npm run db:setup` | keep | compatibility alias still referenced by setup wizard copy | `npm run db:setup` |
| `seed:super-admin` | `node scripts/seed-super-admin.js` | keep | useful low-level bootstrap step | - |
| `seed:foundation` | `node --experimental-strip-types src/db/seeds/foundation.seed.ts` | keep | useful low-level seed for troubleshooting | - |
| `seed:crm-uat` | `node --experimental-strip-types src/db/seeds/crm-uat.seed.ts` | keep | useful low-level data-only seed for troubleshooting | - |
| `seed:system` | old direct seed file | replace | now routes through `scripts/seed-system.ts` wrapper and includes PDF template reseed | `npm run seed:system` |
| `seed:uat` | old direct seed file | replace | now routes through `scripts/seed-uat.ts` wrapper and seeds users before CRM data | `npm run seed:uat` |
| `seed:reset` | not present | add | task-required guarded reset + migrate + reseed orchestration | `npm run seed:reset -- --allow-db-reset` |
| `seed:pdf-template` | `node --experimental-strip-types scripts/reseed-pdf-template-version.ts` | keep | canonical operator-facing PDF template sync | - |
| `seed:pdf-template-version` | same as `seed:pdf-template` | keep | compatibility alias for historical docs and task lineage | `npm run seed:pdf-template` |
| `pdf:reseed` | duplicate reseed alias | remove | overlapping alias with no added behavior | `npm run seed:pdf-template` |
| `pdf:activate:product` | targeted activation command | keep | still useful for product-v1 operator flow | - |
| `migrate:membership-business-roles` | migration utility | keep | one-off but still valid migration helper | - |
| `seed:task-h1-fixture` | fixture utility | keep | still referenced by historical PDF verification docs | - |
| `legacy:seed:task-h1-fixture` | duplicate legacy alias | remove | duplicate of active fixture command | `npm run seed:task-h1-fixture` |
| `verify:task-h1` | fixture verification utility | keep | still referenced by historical docs | - |
| `legacy:verify:task-h1` | duplicate legacy alias | remove | duplicate of active verification command | `npm run verify:task-h1` |
| `verify:task-h3` | historical verification utility | keep | still valid and referenced | - |
| `legacy:verify:task-h3` | duplicate legacy alias | remove | duplicate of active verification command | `npm run verify:task-h3` |
| `verify:encoding` | `node scripts/verify-encoding.mjs` | keep | build safety prerequisite | - |
| `verify:crm-access` | CRM access audit | keep | task-required verification | - |
| `audit:pdf:*` | PDF audit suite | keep | active verification foundation | - |
## Changes Applied
- added `typecheck`
- added `seed:reset`
- moved `seed:system` to `scripts/seed-system.ts`
- moved `seed:uat` to `scripts/seed-uat.ts`
- kept `seed:pdf-template-version` only as compatibility alias
- removed duplicate shorthand and legacy aliases:
- `gen`
- `migrate`
- `studio`
- `pdf:reseed`
- `legacy:seed:task-h1-fixture`
- `legacy:verify:task-h1`
- `legacy:verify:task-h3`
## Seed Architecture Notes
- `seed:system` now guarantees:
- super admin
- foundation data
- active PDF template reseed
- `seed:uat` now guarantees:
- deterministic UAT users and memberships
- CRM role profiles and role assignments
- CRM demo dataset
- `seed:reset` now guarantees:
- explicit `--allow-db-reset` confirmation or `ALLOW_DB_RESET=true`
- database reset safety guard
- migrations reapplied before seeding
- `seed:system` then `seed:uat`

View File

@@ -1,30 +1,61 @@
{ {
"generatedAt": "2026-06-30T01:35:48.376Z", "generatedAt": "2026-06-30T02:56:00.835Z",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "QT2606-1122", "quotationCode": "CR2606-1124",
"expectedFixtureCode": "QT-H5-AUDIT", "expectedFixtureCode": "QT-H5-AUDIT",
"template": { "template": {
"templateName": "ALLA Quotation Standard", "templateName": "ALLA Demo Organization Quotation Standard",
"version": "2.1", "version": "1.0",
"versionId": "86f99414-4656-44a9-9937-8457794dc710", "versionId": "850b13a2-c9ae-4992-89e7-a43f50c34378",
"fieldCount": 44, "fieldCount": 44,
"previousVersion": "2.0", "previousVersion": null,
"deletedFields": [ "deletedFields": [],
"__section_role__customer", "newFields": [
"__section_role__product_items", "Please_do_not",
"__section_role__signature", "app1",
"__section_role__topics", "app1_position",
"items_table", "app2",
"product_items_currency_value", "app2_position",
"product_items_footer_note", "app3",
"product_items_line_168", "app3_position",
"product_items_line_35", "att_label",
"product_items_subtitle", "colon",
"product_items_title", "colon copy",
"product_items_total_label", "colon_att",
"product_items_total_value" "colon_email",
"colon_fax",
"colon_project",
"colon_site",
"colon_tel",
"company1",
"company2",
"company_addr1",
"company_addr2",
"company_email",
"company_tax",
"company_tel",
"customer_addr",
"customer_att",
"customer_email",
"customer_name",
"customer_tel",
"data_topic",
"dear_sirs",
"email_label",
"line",
"project_label",
"project_name",
"quotation_code_data",
"quotation_code_lable",
"quotation_date_data",
"quotation_date_labe",
"quotation_price_data",
"site_label",
"site_location",
"tel_label",
"topic",
"yours_faithfuly"
], ],
"newFields": [],
"duplicateFields": [], "duplicateFields": [],
"unknownFields": [], "unknownFields": [],
"classifications": [ "classifications": [
@@ -232,7 +263,7 @@
"consistency": { "consistency": {
"status": "PASS", "status": "PASS",
"approvedSnapshotAvailable": true, "approvedSnapshotAvailable": true,
"approvedArtifactReference": "artifact:ec300b87-3710-43a4-a63c-a72168fe3933", "approvedArtifactReference": null,
"inconsistentKeys": [] "inconsistentKeys": []
}, },
"integritySummary": { "integritySummary": {

View File

@@ -1,10 +1,10 @@
# PDF Audit Report # PDF Audit Report
- Generated At: 2026-06-30T01:35:48.376Z - Generated At: 2026-06-30T02:56:00.835Z
- Overall Status: PASS - Overall Status: PASS
- Quotation Code: QT2606-1122 - Quotation Code: CR2606-1124
- Template: ALLA Quotation Standard v2.1 - Template: ALLA Demo Organization Quotation Standard v1.0
- Previous Version: 2.0 - Previous Version: None
## Metrics ## Metrics

View File

@@ -4,15 +4,21 @@
```bash ```bash
npm install npm install
ALLOW_DB_RESET=true npm run db:fresh npm run db:fresh
npm exec tsc --noEmit npm run typecheck
npm run dev npm run dev
``` ```
## Fresh UAT or demo data ## Fresh UAT or demo data
```bash ```bash
ALLOW_DB_RESET=true npm run db:fresh:uat npm run db:fresh:uat
```
## Reset and reseed existing local/UAT data
```bash
npm run seed:reset -- --allow-db-reset
``` ```
## Existing database upgrade ## Existing database upgrade
@@ -44,7 +50,7 @@ npm run seed:pdf-template
## Verification ## Verification
```bash ```bash
npm exec tsc --noEmit npm run typecheck
npm run build npm run build
npm run verify:encoding npm run verify:encoding
npm run verify:crm-access npm run verify:crm-access

View File

@@ -0,0 +1,86 @@
# UAT Seed Guide
## Purpose
Seeded UAT data is designed to make the CRM flow visible end-to-end:
`Marketing (MK) -> Sales -> Manager -> CEO`
Default password for all seeded UAT accounts:
```text
UatDemo123!
```
## Seeded Users
| User Email | Role | What this user should see | Expected CRM data | Expected approval actions |
| --- | --- | --- | --- | --- |
| `mk.uat@alla.local` | Marketing | lead intake, assigned lead queue, follow-up workload, dashboard without final commercial authority | newly created and assigned leads including crane and dock door stories | assign leads to sales, monitor follow-up only |
| `sales.crane.uat@alla.local` | Sales - Crane | own crane opportunities and quotations, pricing-visible commercial pipeline for crane work | crane project stories such as Siam Manufacturing / Toyota Gateway Plant | submit crane quotation for approval, track follow-up |
| `sales.dockdoor.uat@alla.local` | Sales - Dock Door | own dock door opportunities and quotations, follow-up workload | dock door stories such as Eastern Logistics / WHA Warehouse | submit dock door quotation, maintain follow-up and revisions |
| `mgr.sales.uat@alla.local` | Sales Manager + Department Manager | team-wide quotation queue, approval steps 1 and 2, broader sales pipeline | pending manager approval, manager-approved waiting CEO, revised quotations | approve, reject, return, and advance quotations |
| `ceo.uat@alla.local` | CEO / Top Manager | executive dashboard, organization-wide quotations, final approval queue | manager-approved quotations waiting final decision, approved deal visibility | final approve or reject quotations |
| `admin.uat@alla.local` | CRM Admin | full setup/admin visibility including master options, sequences, approvals, document templates | complete seeded organization data | verify configuration, inspect all data, no special workflow dependency |
## Seeded Workflow Scenarios
### Scenario A: Crane Project
- Customer: `Siam Manufacturing Co., Ltd.`
- End Customer: `Toyota Gateway Plant`
- Project: `Overhead Crane Installation Phase 1`
- Product Type: `Crane`
- Owner flow: MK -> Sales Crane -> Sales Manager -> CEO
- Expected status: manager-approved and waiting final CEO approval
### Scenario B: Dock Door Project
- Customer: `Eastern Logistics Co., Ltd.`
- End Customer: `WHA Warehouse`
- Project: `Loading Dock Expansion`
- Product Type: `Dock Door`
- Owner flow: MK -> Sales Dock Door
- Expected status: draft/follow-up style commercial progress before final submission
### Scenario C: Lost / No Quotation
- Customer: `Demo Lost Customer`
- Expected status: `No Quotation`
- Expected reason: `Budget not approved`
### Scenario D: Follow-up Due States
- seeded at least one follow-up due today
- seeded at least one overdue follow-up
- seeded at least one upcoming follow-up
## Approval Flow Expectation
Seeded workflow: `Quotation Standard Approval`
1. Sales submits quotation
2. Sales Manager approves
3. Department Manager approves
4. CEO final approves
For UAT simplicity, `mgr.sales.uat@alla.local` holds both manager stages so the queue is easy to validate visually.
## Seed Commands
```bash
npm run seed:system
npm run seed:uat
npm run seed:reset -- --allow-db-reset
```
## Verification
```bash
npm run typecheck
npm run build
npm run seed:reset -- --allow-db-reset
npm run verify:encoding
npm run verify:crm-access
npm run audit:pdf
```

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -12,47 +12,42 @@
"format": "oxfmt --write .", "format": "oxfmt --write .",
"format:check": "oxfmt --check .", "format:check": "oxfmt --check .",
"prepare": "husky", "prepare": "husky",
"gen": "npm run verify:encoding && npx drizzle-kit generate", "typecheck": "tsc --noEmit",
"db:generate": "npx drizzle-kit generate", "db:generate": "npx drizzle-kit generate",
"db:migrate": "npx drizzle-kit migrate", "db:migrate": "npx drizzle-kit migrate",
"db:studio": "npx drizzle-kit studio", "db:studio": "npx drizzle-kit studio",
"db:reset": "node --experimental-strip-types scripts/db/reset-database.ts", "db:reset": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/db/reset-database.ts",
"db:setup": "npm run db:migrate && npm run seed:system", "db:setup": "npm run db:migrate && npm run seed:system",
"db:setup:uat": "npm run db:setup && npm run seed:uat", "db:setup:uat": "npm run db:setup && npm run seed:uat",
"db:fresh": "npm run db:reset && npm run db:migrate && npm run seed:system", "db:fresh": "npm run db:reset -- --allow-db-reset && npm run db:migrate && npm run seed:system",
"db:fresh:uat": "npm run db:fresh && npm run seed:uat", "db:fresh:uat": "npm run db:reset -- --allow-db-reset && npm run db:migrate && npm run seed:system && npm run seed:uat",
"migrate": "npm run db:migrate", "setup:db": "npm run db:setup",
"studio": "npm run db:studio",
"seed:super-admin": "node scripts/seed-super-admin.js", "seed:super-admin": "node scripts/seed-super-admin.js",
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", "seed:foundation": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types src/db/seeds/foundation.seed.ts",
"seed:crm-uat": "node --experimental-strip-types src/db/seeds/crm-uat.seed.ts", "seed:crm-uat": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types src/db/seeds/crm-uat.seed.ts",
"seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts", "seed:system": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/seed-system.ts",
"seed:system": "node --experimental-strip-types src/db/seeds/system.seed.ts", "seed:uat": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/seed-uat.ts",
"seed:uat": "node --experimental-strip-types src/db/seeds/uat.seed.ts", "seed:reset": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/seed-reset.ts",
"seed:pdf-template": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts", "seed:pdf-template": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/reseed-pdf-template-version.ts",
"seed:pdf-template-version": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/reseed-pdf-template-version.ts",
"migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts", "migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts",
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js", "seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
"legacy:seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
"verify:task-h1": "node scripts/verify-task-h1.js", "verify:task-h1": "node scripts/verify-task-h1.js",
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js", "verify:task-h3": "node scripts/verify-task-h3.js",
"legacy:verify:task-h1": "node scripts/verify-task-h1.js",
"legacy:verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
"verify:crm-access": "node --experimental-strip-types scripts/security/verify-crm-access.ts", "verify:crm-access": "node --experimental-strip-types scripts/security/verify-crm-access.ts",
"verify:encoding": "node scripts/verify-encoding.mjs",
"audit:pdf:inventory": "node --experimental-strip-types scripts/audit-pdf-template-inventory.ts", "audit:pdf:inventory": "node --experimental-strip-types scripts/audit-pdf-template-inventory.ts",
"audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts", "audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts",
"audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts", "audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts",
"audit:pdf:report": "node --experimental-strip-types scripts/generate-pdf-audit-report.ts", "audit:pdf:report": "node --experimental-strip-types scripts/generate-pdf-audit-report.ts",
"verify:encoding": "node scripts/verify-encoding.mjs",
"audit:pdf:versions": "node --experimental-strip-types scripts/audit-template-version-integrity.ts", "audit:pdf:versions": "node --experimental-strip-types scripts/audit-template-version-integrity.ts",
"audit:pdf:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts", "audit:pdf:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts",
"audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts", "audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts",
"audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts", "audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts",
"audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts", "audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts",
"audit:pdf:visual": "npx tsx scripts/audit-pdf-visual-regression.ts", "audit:pdf:visual": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts",
"audit:pdf:visual:baseline": "npx tsx scripts/audit-pdf-visual-regression.ts -- --write-baseline", "audit:pdf:visual:baseline": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts --refresh-baseline",
"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity", "audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity",
"setup:db": "npm run db:setup",
"pdf:reseed": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
"pdf:activate:product": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate" "pdf:activate:product": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate"
}, },
"dependencies": { "dependencies": {

433
plans/task-clean-pk.md Normal file
View File

@@ -0,0 +1,433 @@
# Task: Clean package.json Scripts + UAT Seed + Resettable System Seed
## Role
You are a Next.js full-stack engineer working on ALLA OS CRM.
Tech stack:
* Next.js Full Stack
* TypeScript
* Drizzle ORM
* PostgreSQL
* shadcn/ui
* Auth.js / Keycloak-compatible user model
* pdfme document template system
## Objective
Clean unused `package.json` scripts and implement deterministic seed scripts for UAT/demo data that clearly shows the CRM workflow:
```text
Marketing (MK) -> Sales -> Manager -> CEO
```
The system must be resettable to a clean starting state at any time.
---
## Part 1: Audit and Clean package.json
Inspect `package.json`, `scripts/`, `src/db/seeds/`, `drizzle/`, and existing documentation.
### Requirements
1. Identify scripts that are:
* broken
* duplicated
* unused
* pointing to missing files
* legacy from old architecture
* confusing or overlapping
2. Do not delete blindly.
* First create a report file:
```text
docs/audit/package-scripts-cleanup.md
```
3. The report must include:
```text
Script Name
Current Command
Status: keep / rename / remove / replace
Reason
Replacement Command if any
```
4. Then update `package.json`.
### Recommended final script groups
```json
{
"scripts": {
"dev": "...",
"build": "...",
"start": "...",
"lint": "...",
"typecheck": "tsc --noEmit",
"db:generate": "...",
"db:migrate": "...",
"db:studio": "...",
"db:fresh": "...",
"db:fresh:uat": "...",
"seed:system": "...",
"seed:uat": "...",
"seed:reset": "...",
"seed:pdf-template": "...",
"verify:encoding": "...",
"verify:crm-access": "...",
"audit:pdf": "..."
}
}
```
Use the actual project commands after inspection.
---
## Part 2: Implement Resettable Seed Architecture
Create or normalize seed scripts under:
```text
src/db/seeds/
scripts/
```
Expected command behavior:
### 1. System seed
```bash
npm run seed:system
```
Purpose:
* create minimum required system data
* organizations
* branches
* product types
* roles
* permissions
* master options
* document sequences
* approval definitions
* PDF template records
Must be idempotent.
---
### 2. UAT seed
```bash
npm run seed:uat
```
Purpose:
Create realistic demo data for UAT so each role can log in and clearly see its own view.
Seed role flow:
```text
MK User
creates Lead
Sales User
receives assigned Lead
works Opportunity
creates Quotation
Manager User
sees team pipeline
approves quotation step 1 or 2
CEO User
sees executive dashboard
final approval
```
---
### 3. Full reset seed
```bash
ALLOW_DB_RESET=true npm run seed:reset
```
Purpose:
Reset all business/UAT data and rebuild from clean baseline.
Rules:
* Must require `ALLOW_DB_RESET=true`
* Must refuse to run without this env flag
* Must never run accidentally in production
* Must clear CRM transactional tables in dependency-safe order
* Must preserve migrations
* Must rebuild system seed
* Must rebuild UAT seed
Expected flow:
```text
validate environment
clear transactional CRM data
clear configurable seed-owned master data if needed
run seed:system
run seed:uat
run audit/verification
```
---
## Part 3: UAT Demo Users
Create clear users and memberships.
Example users:
```text
mk.uat@alla.local
sales.crane.uat@alla.local
sales.dockdoor.uat@alla.local
mgr.sales.uat@alla.local
ceo.uat@alla.local
admin.uat@alla.local
```
Each user must have:
* deterministic id
* name
* email
* role
* businessRole
* permissions
* organization membership
* branch scope
* product type scope where applicable
Use existing schema and permission model. Do not invent unrelated tables.
---
## Part 4: UAT Demo Data Scenario
Create data that makes the dashboard and permissions obvious.
### Scenario A: Crane Project
```text
Customer: Siam Manufacturing Co., Ltd.
End Customer: Toyota Gateway Plant
Project: Overhead Crane Installation Phase 1
Product Type: Crane
Lead created by: MK
Assigned to: Sales Crane
Manager: Sales Manager
Final approver: CEO
Quotation status: Pending Approval / Approved
Value: realistic THB amount
```
### Scenario B: Dock Door Project
```text
Customer: Eastern Logistics Co., Ltd.
End Customer: WHA Warehouse
Project: Loading Dock Expansion
Product Type: Dock Door
Lead created by: MK
Assigned to: Sales Dock Door
Quotation status: Draft / Follow-up
```
### Scenario C: Lost / No Quotation
```text
Customer: Demo Lost Customer
Reason: Budget not approved / competitor selected
Status: Closed Lost or No Quotation
```
### Scenario D: Follow-up Due
Create at least:
* one due today
* one overdue
* one upcoming
This helps test calendar/dashboard/follow-up UI.
---
## Part 5: Approval Flow Seed
Create approval definition:
```text
Quotation Standard Approval
Step 1: Sales Manager
Step 2: Department Manager or Business Manager
Step 3: CEO
```
For UAT, make the flow visually simple:
```text
Sales submits quotation
Manager approves
CEO final approves
```
Seed at least:
* one draft quotation
* one submitted quotation
* one manager-approved quotation waiting for CEO
* one fully approved quotation
* one rejected quotation if supported
---
## Part 6: Document Sequence Seed
Seed document sequences for all main document types:
```text
Lead
Opportunity
Quotation
PO if supported
```
Code format:
```text
ProductType + YYMM + running
CR2606-001
DK2606-001
SOL2606-001
SV2606-001
SP2606-001
```
Branch should represent internal ALLA branch/business unit, not customer branch.
---
## Part 7: PDF Template Seed
Ensure PDF template seeding is included.
Runtime must use DB template versions, not raw JSON directly.
Add or confirm command:
```bash
npm run seed:pdf-template
```
If template reset is needed, add:
```bash
npm run seed:pdf-template:reset
```
Expected behavior:
* deactivate old active template versions if needed
* insert/update template version from source JSON
* activate the correct version
* run `npm run audit:pdf`
Do not leave multiple active versions for the same template.
---
## Part 8: Safety Requirements
1. All seed scripts must be idempotent.
2. Running `npm run seed:uat` twice must not duplicate data.
3. Reset must require explicit env flag.
4. Scripts must log what they changed.
5. Use deterministic IDs or stable unique keys.
6. Do not hide errors with empty catch blocks.
7. Use transactions where practical.
8. Add verification output after seed.
---
## Part 9: Verification Commands
After implementation, run:
```bash
npm run typecheck
npm run build
npm run seed:reset
npm run verify:encoding
npm run verify:crm-access
npm run audit:pdf
```
If some commands do not exist, either:
* add them properly, or
* document why they are not available.
---
## Deliverables
Create or update:
```text
package.json
docs/audit/package-scripts-cleanup.md
docs/seeding/uat-seed-guide.md
scripts/seed-system.ts
scripts/seed-uat.ts
scripts/seed-reset.ts
scripts/reseed-pdf-template-version.ts if needed
src/db/seeds/*
```
The UAT seed guide must include:
```text
User
Email
Role
What this user should see
Expected CRM data
Expected approval actions
```
---
## Acceptance Criteria
* `package.json` has no dead scripts.
* `npm run seed:system` works.
* `npm run seed:uat` works.
* `ALLOW_DB_RESET=true npm run seed:reset` resets and reseeds cleanly.
* UAT data clearly demonstrates MK -> Sales -> Manager -> CEO.
* Each role sees different data according to permission.
* Quotation approval flow has testable documents.
* PDF template audit passes.
* No duplicate active PDF template version exists.
* TypeScript passes.
* Build passes.

View File

@@ -42,11 +42,21 @@ function loadLocalEnv() {
loadEnvFile(path.resolve(process.cwd(), '.env')); loadEnvFile(path.resolve(process.cwd(), '.env'));
} }
const ALLOW_DB_RESET_FLAG = '--allow-db-reset';
function assertSafeToReset(databaseUrl: string) { function assertSafeToReset(databaseUrl: string) {
if (process.env.ALLOW_DB_RESET !== 'true') { const allowDbReset =
throw new Error('ALLOW_DB_RESET=true is required.'); process.env.ALLOW_DB_RESET === 'true' ||
process.argv.includes(ALLOW_DB_RESET_FLAG);
if (!allowDbReset) {
throw new Error(
`Database reset requires explicit confirmation. Run "npm run db:reset -- ${ALLOW_DB_RESET_FLAG}".`
);
} }
process.env.ALLOW_DB_RESET = 'true';
if ((process.env.NODE_ENV ?? '').toLowerCase() === 'production') { if ((process.env.NODE_ENV ?? '').toLowerCase() === 'production') {
throw new Error('Database reset is blocked in NODE_ENV=production.'); throw new Error('Database reset is blocked in NODE_ENV=production.');
} }

110
scripts/lib/seed-utils.ts Normal file
View File

@@ -0,0 +1,110 @@
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import postgres from 'postgres';
export type SqlClient = ReturnType<typeof postgres>;
function loadEnvFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return;
}
const content = fs.readFileSync(filePath, 'utf8');
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const separatorIndex = line.indexOf('=');
if (separatorIndex === -1) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (!(key in process.env)) {
process.env[key] = value;
}
}
}
export function loadLocalEnv() {
loadEnvFile(path.resolve(process.cwd(), '.env.local'));
loadEnvFile(path.resolve(process.cwd(), '.env'));
}
export function requireEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export function createSqlClient(): SqlClient {
loadLocalEnv();
return postgres(requireEnv('DATABASE_URL'), { prepare: false });
}
export function deterministicId(input: string): string {
const digest = createHash('sha256').update(input).digest('hex');
const part3 = `5${digest.slice(13, 16)}`;
const part4 = `a${digest.slice(17, 20)}`;
return [
digest.slice(0, 8),
digest.slice(8, 12),
part3,
part4,
digest.slice(20, 32)
].join('-');
}
export const NODE_TS_RUNTIME_ARGS = [
'--disable-warning=ExperimentalWarning',
'--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
'--experimental-strip-types'
];
export function runNodeScript(label: string, scriptPath: string, args: string[] = []) {
const result = spawnSync(
process.execPath,
[...NODE_TS_RUNTIME_ARGS, scriptPath, ...args],
{
stdio: 'inherit',
env: process.env
}
);
if (result.status !== 0) {
throw new Error(`[${label}] failed with exit code ${result.status ?? 1}`);
}
}
export function runCommand(label: string, command: string, args: string[] = []) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
shell: process.platform === 'win32'
});
if (result.status !== 0) {
throw new Error(`[${label}] failed with exit code ${result.status ?? 1}`);
}
}

25
scripts/seed-reset.ts Normal file
View File

@@ -0,0 +1,25 @@
import { runCommand, runNodeScript } from './lib/seed-utils.ts';
const ALLOW_DB_RESET_FLAG = '--allow-db-reset';
function main() {
const allowDbReset =
process.env.ALLOW_DB_RESET === 'true' ||
process.argv.includes(ALLOW_DB_RESET_FLAG);
if (!allowDbReset) {
throw new Error(
`seed:reset requires explicit confirmation. Run "npm run seed:reset -- ${ALLOW_DB_RESET_FLAG}".`
);
}
// Normalize the guard so downstream reset scripts keep the same safety contract.
process.env.ALLOW_DB_RESET = 'true';
runNodeScript('seed:reset:db-reset', 'scripts/db/reset-database.ts');
runCommand('seed:reset:db-migrate', 'npx', ['drizzle-kit', 'migrate']);
runNodeScript('seed:reset:seed-system', 'scripts/seed-system.ts');
runNodeScript('seed:reset:seed-uat', 'scripts/seed-uat.ts');
}
main();

7
scripts/seed-system.ts Normal file
View File

@@ -0,0 +1,7 @@
import { runNodeScript } from './lib/seed-utils.ts';
function main() {
runNodeScript('seed:system:core', 'src/db/seeds/system.seed.ts');
}
main();

7
scripts/seed-uat.ts Normal file
View File

@@ -0,0 +1,7 @@
import { runNodeScript } from './lib/seed-utils.ts';
function main() {
runNodeScript('seed:uat:core', 'src/db/seeds/uat.seed.ts');
}
main();

View File

@@ -1,5 +1,9 @@
'use client'; "use client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -7,8 +11,8 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from "@/components/ui/dropdown-menu";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -22,18 +26,18 @@ import {
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarRail SidebarRail,
} from '@/components/ui/sidebar'; } from "@/components/ui/sidebar";
import { UserAvatarProfile } from '@/components/user-avatar-profile'; import { UserAvatarProfile } from "@/components/user-avatar-profile";
import { navGroups } from '@/config/nav-config'; import { navGroups } from "@/config/nav-config";
import { useMediaQuery } from '@/hooks/use-media-query'; import { useMediaQuery } from "@/hooks/use-media-query";
import { useFilteredNavGroups } from '@/hooks/use-nav'; import { useFilteredNavGroups } from "@/hooks/use-nav";
import Link from 'next/link'; import Link from "next/link";
import { signOut, useSession } from 'next-auth/react'; import { signOut, useSession } from "next-auth/react";
import { usePathname, useRouter } from 'next/navigation'; import { usePathname, useRouter } from "next/navigation";
import * as React from 'react'; import * as React from "react";
import { Icons } from '../icons'; import { Icons } from "../icons";
import { OrgSwitcher } from '../org-switcher'; import { OrgSwitcher } from "../org-switcher";
export default function AppSidebar() { export default function AppSidebar() {
const pathname = usePathname(); const pathname = usePathname();
@@ -44,59 +48,67 @@ export default function AppSidebar() {
const user = session?.user ?? null; const user = session?.user ?? null;
const organization = session?.user?.activeOrganizationId; const organization = session?.user?.activeOrganizationId;
const canManageOrganization = const canManageOrganization =
session?.user?.systemRole === 'super_admin' || session?.user?.systemRole === "super_admin" ||
(!!organization && session?.user?.activeMembershipRole === 'admin'); (!!organization && session?.user?.activeMembershipRole === "admin");
React.useEffect(() => { React.useEffect(() => {
// Side effects based on sidebar state changes // Side effects based on sidebar state changes
}, [isOpen]); }, [isOpen]);
React.useEffect(() => { React.useEffect(() => {
if (process.env.NODE_ENV !== 'development') { if (process.env.NODE_ENV !== "development") {
return; return;
} }
console.info('[sidebar-debug]', { console.info("[sidebar-debug]", {
systemRole: session?.user?.systemRole ?? null, systemRole: session?.user?.systemRole ?? null,
activeOrganizationId: session?.user?.activeOrganizationId ?? null, activeOrganizationId: session?.user?.activeOrganizationId ?? null,
activeMembershipRole: session?.user?.activeMembershipRole ?? null, activeMembershipRole: session?.user?.activeMembershipRole ?? null,
activePermissions: session?.user?.activePermissions ?? [], activePermissions: session?.user?.activePermissions ?? [],
visibleItems: filteredGroups.flatMap((group) => group.items.map((item) => item.title)) visibleItems: filteredGroups.flatMap((group) =>
group.items.map((item) => item.title),
),
}); });
}, [ }, [
filteredGroups, filteredGroups,
session?.user?.activeOrganizationId, session?.user?.activeOrganizationId,
session?.user?.activePermissions, session?.user?.activePermissions,
session?.user?.activeMembershipRole, session?.user?.activeMembershipRole,
session?.user?.systemRole session?.user?.systemRole,
]); ]);
return ( return (
<Sidebar collapsible='icon'> <Sidebar collapsible="icon">
<SidebarHeader className='group-data-[collapsible=icon]:pt-4'> <SidebarHeader className="group-data-[collapsible=icon]:pt-4">
<OrgSwitcher /> <OrgSwitcher />
</SidebarHeader> </SidebarHeader>
<SidebarContent className='overflow-x-hidden'> <SidebarContent className="overflow-x-hidden">
{filteredGroups.map((group) => ( {filteredGroups.map((group) => (
<SidebarGroup key={group.label || 'ungrouped'} className='py-0'> <SidebarGroup key={group.label || "ungrouped"} className="py-0">
{group.label && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>} {group.label && (
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
)}
<SidebarMenu> <SidebarMenu>
{group.items.map((item) => { {group.items.map((item) => {
const Icon = item.icon ? Icons[item.icon] : Icons.logo; const Icon = item.icon ? Icons[item.icon] : Icons.logo;
const itemKey = item.url || `${group.label ?? 'group'}-${item.title}`; const itemKey =
item.url || `${group.label ?? "group"}-${item.title}`;
return item?.items && item?.items?.length > 0 ? ( return item?.items && item?.items?.length > 0 ? (
<Collapsible <Collapsible
key={itemKey} key={itemKey}
asChild asChild
defaultOpen={item.isActive} defaultOpen={item.isActive}
className='group/collapsible' className="group/collapsible"
> >
<SidebarMenuItem> <SidebarMenuItem>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}> <SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
>
{item.icon && <Icon />} {item.icon && <Icon />}
<span>{item.title}</span> <span>{item.title}</span>
<Icons.chevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' /> <Icons.chevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton> </SidebarMenuButton>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
@@ -105,7 +117,10 @@ export default function AppSidebar() {
<SidebarMenuSubItem <SidebarMenuSubItem
key={subItem.url || `${itemKey}-${subItem.title}`} key={subItem.url || `${itemKey}-${subItem.title}`}
> >
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}> <SidebarMenuSubButton
asChild
isActive={pathname === subItem.url}
>
<Link href={subItem.url}> <Link href={subItem.url}>
<span>{subItem.title}</span> <span>{subItem.title}</span>
</Link> </Link>
@@ -141,49 +156,57 @@ export default function AppSidebar() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size='lg' size="lg"
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground' className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
{user && ( {user && (
<UserAvatarProfile className='h-8 w-8 rounded-lg' showInfo user={user} /> <UserAvatarProfile
className="h-8 w-8 rounded-lg"
showInfo
user={user}
/>
)} )}
<Icons.chevronsDown className='ml-auto size-4' /> <Icons.chevronsDown className="ml-auto size-4" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg' className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side='bottom' side="bottom"
align='end' align="end"
sideOffset={4} sideOffset={4}
> >
<DropdownMenuLabel className='p-0 font-normal'> <DropdownMenuLabel className="p-0 font-normal">
<div className='px-1 py-1.5'> <div className="px-1 py-1.5">
{user && ( {user && (
<UserAvatarProfile className='h-8 w-8 rounded-lg' showInfo user={user} /> <UserAvatarProfile
className="h-8 w-8 rounded-lg"
showInfo
user={user}
/>
)} )}
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/dashboard/profile')}> <DropdownMenuItem
<Icons.account className='mr-2 h-4 w-4' /> onClick={() => router.push("/dashboard/profile")}
>
<Icons.account className="mr-2 h-4 w-4" />
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
{canManageOrganization && ( <DropdownMenuItem
<DropdownMenuItem onClick={() => router.push('/dashboard/billing')}> onClick={() => router.push("/dashboard/notifications")}
<Icons.creditCard className='mr-2 h-4 w-4' /> >
Billing <Icons.notification className="mr-2 h-4 w-4" />
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => router.push('/dashboard/notifications')}>
<Icons.notification className='mr-2 h-4 w-4' />
Notifications Notifications
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut({ redirectTo: '/auth/sign-in' })}> <DropdownMenuItem
<Icons.logout className='mr-2 h-4 w-4' /> onClick={() => void signOut({ redirectTo: "/auth/sign-in" })}
>
<Icons.logout className="mr-2 h-4 w-4" />
Sign out Sign out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -1,5 +1,5 @@
'use client'; "use client";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -7,11 +7,11 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from "@/components/ui/dropdown-menu";
import { UserAvatarProfile } from '@/components/user-avatar-profile'; import { UserAvatarProfile } from "@/components/user-avatar-profile";
import { signOut, useSession } from 'next-auth/react'; import { signOut, useSession } from "next-auth/react";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
export function UserNav() { export function UserNav() {
const { data: session } = useSession(); const { data: session } = useSession();
const user = session?.user; const user = session?.user;
@@ -21,28 +21,37 @@ export function UserNav() {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant='ghost' className='relative h-8 w-8 rounded-full'> <Button variant="ghost" className="relative h-8 w-8 rounded-full">
<UserAvatarProfile user={user} /> <UserAvatarProfile user={user} />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' sideOffset={10} forceMount> <DropdownMenuContent
<DropdownMenuLabel className='font-normal'> className="w-56"
<div className='flex flex-col space-y-1'> align="end"
<p className='text-sm leading-none font-medium'>{user.name}</p> sideOffset={10}
<p className='text-muted-foreground text-xs leading-none'>{user.email}</p> forceMount
>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm leading-none font-medium">{user.name}</p>
<p className="text-muted-foreground text-xs leading-none">
{user.email}
</p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push('/dashboard/profile')}> <DropdownMenuItem onClick={() => router.push("/dashboard/profile")}>
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem> <DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem>New Team</DropdownMenuItem> <DropdownMenuItem>New Team</DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void signOut({ redirectTo: '/auth/sign-in' })}> <DropdownMenuItem
onClick={() => void signOut({ redirectTo: "/auth/sign-in" })}
>
Sign out Sign out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -345,6 +345,7 @@ type UserContext = {
admin: UserMembershipRow; admin: UserMembershipRow;
marketing: UserMembershipRow; marketing: UserMembershipRow;
sales: UserMembershipRow[]; sales: UserMembershipRow[];
salesByProductType: Partial<Record<string, UserMembershipRow>>;
salesManager: UserMembershipRow; salesManager: UserMembershipRow;
departmentManager: UserMembershipRow; departmentManager: UserMembershipRow;
topManager: UserMembershipRow; topManager: UserMembershipRow;
@@ -352,33 +353,41 @@ type UserContext = {
const SEED_TAG = "crm-uat-seed-v1"; const SEED_TAG = "crm-uat-seed-v1";
const PERIOD = "2606"; const PERIOD = "2606";
const UAT_EMAILS = {
marketing: "mk.uat@alla.local",
salesCrane: "sales.crane.uat@alla.local",
salesDockDoor: "sales.dockdoor.uat@alla.local",
salesManager: "mgr.sales.uat@alla.local",
topManager: "ceo.uat@alla.local",
admin: "admin.uat@alla.local",
} as const;
const STORIES: StoryConfig[] = [ const STORIES: StoryConfig[] = [
{ {
slug: "website-warehouse-crane-win", slug: "website-warehouse-crane-win",
title: "Lead received from website and won after approval", title: "Siam Manufacturing crane project waiting final CEO approval",
customerName: "บริษัท บางกอก ฟู้ด อินดัสทรี จำกัด", customerName: "บริษัท บางกอก ฟู้ด อินดัสทรี จำกัด",
province: "Bangkok", province: "Chachoengsao",
district: "Min Buri", district: "Bang Pakong",
branchCode: "head_office", branchCode: "head_office",
customerGroupCode: "industrial", customerGroupCode: "industrial",
customerSubGroupCode: "factory", customerSubGroupCode: "factory",
productTypeCode: "crane", productTypeCode: "crane",
quotationTypeCode: "crane", quotationTypeCode: "crane",
priorityCode: "high", priorityCode: "high",
leadChannelCode: "website", leadChannelCode: "referral",
awarenessCode: "google_search_website", awarenessCode: "google_search_website",
leadStatusCode: "assigned", leadStatusCode: "assigned",
leadFollowupStatusCode: "waiting_po", leadFollowupStatusCode: "drawing_followup",
opportunityStatusCode: "negotiation", opportunityStatusCode: "negotiation",
chancePercent: 80, chancePercent: 78,
estimatedValue: 12400000, estimatedValue: 8750000,
projectName: "Warehouse Crane Installation Phase 2", projectName: "Overhead Crane Installation Phase 1",
projectLocation: "Bangkok Food Industrial Estate, Min Buri", projectLocation: "Toyota Gateway Plant",
leadDescription: leadDescription:
"Customer asked for top-running overhead crane to support raw material warehouse expansion.", "Marketing qualified the crane project and handed it to the crane sales queue for quotation approval.",
quotePlan: "accepted", quotePlan: "pending_step_2",
opportunityOutcome: "won", opportunityOutcome: "open",
multiParties: [ multiParties: [
{ {
roleCode: "contractor", roleCode: "contractor",
@@ -392,57 +401,57 @@ const STORIES: StoryConfig[] = [
}, },
{ {
slug: "facebook-lost-price", slug: "facebook-lost-price",
title: "Lead lost to competitor because price too high", title: "Demo lost customer with no quotation because budget not approved",
customerName: "บริษัท ไทย ออโต้ เพรส จำกัด", customerName: "บริษัท ไทย ออโต้ เพรส จำกัด",
province: "Samut Prakan", province: "Bangkok",
district: "Bang Phli", district: "Lat Krabang",
branchCode: "factory", branchCode: "service",
customerGroupCode: "industrial", customerGroupCode: "industrial",
customerSubGroupCode: "factory", customerSubGroupCode: "warehouse",
productTypeCode: "crane", productTypeCode: "dockdoor",
quotationTypeCode: "crane", quotationTypeCode: "dockdoor",
priorityCode: "normal", priorityCode: "normal",
leadChannelCode: "facebook", leadChannelCode: "website",
awarenessCode: "social_media", awarenessCode: "google_search_website",
leadStatusCode: "closed_lost", leadStatusCode: "cancel",
leadFollowupStatusCode: "quotation_preparation", leadFollowupStatusCode: "budget_pending",
opportunityStatusCode: "quotation_created", opportunityStatusCode: "qualification",
chancePercent: 45, chancePercent: 20,
estimatedValue: 5800000, estimatedValue: 1900000,
projectName: "Stamping Line Hoist Replacement", projectName: "Budget Hold Demo Project",
projectLocation: "Bang Phli Industrial Estate", projectLocation: "Lat Krabang Logistics Zone",
leadDescription: leadDescription:
"Customer compared 5 ton monorail hoist replacement between ALLA and Konecranes.", "Opportunity was stopped before quotation because customer budget was not approved and competitor was still under consideration.",
competitor: "Konecranes", competitor: "Competitor Selected",
quotePlan: "sent_follow_up", quotePlan: "cancelled",
opportunityOutcome: "lost", opportunityOutcome: "no_quotation",
lostReasonCode: "price_too_high", noQuotationReasonCode: "budget_not_ready",
}, },
{ {
slug: "revision-r02-approved", slug: "revision-r02-approved",
title: "Customer requested revision and approved revision R02", title: "Eastern Logistics dock door project in draft and follow-up stage",
customerName: "บริษัท กรีน โลจิสติกส์ พาร์ค จำกัด", customerName: "บริษัท กรีน โลจิสติกส์ พาร์ค จำกัด",
province: "Pathum Thani", province: "Chonburi",
district: "Khlong Luang", district: "Si Racha",
branchCode: "service", branchCode: "service",
customerGroupCode: "industrial", customerGroupCode: "industrial",
customerSubGroupCode: "logistics", customerSubGroupCode: "warehouse",
productTypeCode: "dockdoor", productTypeCode: "dockdoor",
quotationTypeCode: "dockdoor", quotationTypeCode: "dockdoor",
priorityCode: "high", priorityCode: "high",
leadChannelCode: "referral", leadChannelCode: "referral",
awarenessCode: "word_of_mouth", awarenessCode: "word_of_mouth",
leadStatusCode: "assigned", leadStatusCode: "assigned",
leadFollowupStatusCode: "waiting_po", leadFollowupStatusCode: "quotation_preparation",
opportunityStatusCode: "negotiation", opportunityStatusCode: "proposal_preparation",
chancePercent: 70, chancePercent: 61,
estimatedValue: 9300000, estimatedValue: 4600000,
projectName: "Cold Chain Dock Leveller Upgrade", projectName: "Loading Dock Expansion",
projectLocation: "Khlong Luang Logistics Campus", projectLocation: "WHA Warehouse",
leadDescription: leadDescription:
"Customer requested revision after consultant changed dock layout and bumper specification.", "Dock door salesperson is still following up layout confirmation before final quotation submission.",
quotePlan: "rejected_then_revised", quotePlan: "sent_follow_up",
opportunityOutcome: "won", opportunityOutcome: "open",
multiParties: [ multiParties: [
{ {
roleCode: "consultant", roleCode: "consultant",
@@ -882,6 +891,30 @@ function buildDocumentCode(prefix: string, number: number) {
return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`; return `${prefix}${PERIOD}-${String(number).padStart(3, "0")}`;
} }
function buildQuotationCode(productTypeCode: string, number: number) {
const prefixByProductType: Record<string, string> = {
crane: "CR",
dockdoor: "DK",
solarcell: "SOL",
service: "SV",
spare_part: "SP",
};
return buildDocumentCode(prefixByProductType[productTypeCode] ?? "QT", number);
}
function pickSalesUser(
users: UserContext,
productTypeCode: string | undefined,
index: number
) {
if (productTypeCode && users.salesByProductType[productTypeCode]) {
return users.salesByProductType[productTypeCode]!;
}
return choose(users.sales, index);
}
function seededNote(scope: string) { function seededNote(scope: string) {
return `[${SEED_TAG}:${scope}]`; return `[${SEED_TAG}:${scope}]`;
} }
@@ -1027,21 +1060,48 @@ function buildUserContext(rows: UserMembershipRow[]): UserContext {
} }
const first = rows[0]; const first = rows[0];
const byRole = (role: string) => const byRole = (role: string) => rows.find((row) => row.businessRole === role) ?? null;
rows.find((row) => row.businessRole === role) ?? null; const byEmail = (email: string) =>
const salesRows = rows.filter((row) => row.businessRole === "sales"); rows.find((row) => row.email.toLowerCase() === email.toLowerCase()) ?? null;
const fallbackSalesRows = rows.filter((row) => row.businessRole === "sales");
const preferredSalesRows = [
byEmail(UAT_EMAILS.salesCrane),
byEmail(UAT_EMAILS.salesDockDoor),
].filter((row): row is UserMembershipRow => row !== null);
const salesRows = preferredSalesRows.length
? preferredSalesRows
: fallbackSalesRows.length
? fallbackSalesRows
: [rows.find((row) => row.membershipRole === "admin") ?? first];
const admin = const admin =
byEmail(UAT_EMAILS.admin) ??
rows.find((row) => row.membershipRole === "admin") ?? rows.find((row) => row.membershipRole === "admin") ??
rows.find((row) => row.systemRole === "super_admin") ?? rows.find((row) => row.systemRole === "super_admin") ??
first; first;
return { return {
admin, admin,
marketing: byRole("sales_support") ?? admin, marketing:
sales: salesRows.length ? salesRows : [admin], byEmail(UAT_EMAILS.marketing) ??
salesManager: byRole("sales_manager") ?? admin, byRole("marketing") ??
departmentManager: byRole("department_manager") ?? admin, byRole("sales_support") ??
topManager: byRole("top_manager") ?? admin, admin,
sales: salesRows,
salesByProductType: {
crane: byEmail(UAT_EMAILS.salesCrane) ?? preferredSalesRows[0] ?? salesRows[0],
dockdoor:
byEmail(UAT_EMAILS.salesDockDoor) ??
preferredSalesRows.at(-1) ??
salesRows.at(-1) ??
salesRows[0],
},
salesManager: byEmail(UAT_EMAILS.salesManager) ?? byRole("sales_manager") ?? admin,
departmentManager:
byEmail(UAT_EMAILS.salesManager) ??
byRole("department_manager") ??
byRole("sales_manager") ??
admin,
topManager: byEmail(UAT_EMAILS.topManager) ?? byRole("top_manager") ?? admin,
}; };
} }
@@ -1395,7 +1455,7 @@ function buildLeadAndOpportunityData(
} }
const contact = primaryContactByCustomerId.get(customer.id) ?? null; const contact = primaryContactByCustomerId.get(customer.id) ?? null;
const createdAt = daysAgo(88 - storyIndex * 4); const createdAt = daysAgo(88 - storyIndex * 4);
const assignedUser = choose(users.sales, storyIndex); const assignedUser = pickSalesUser(users, story.productTypeCode, storyIndex);
const assignedAt = addDays(createdAt, 4); const assignedAt = addDays(createdAt, 4);
const statusId = getOptionId( const statusId = getOptionId(
optionMap, optionMap,
@@ -2004,7 +2064,7 @@ function buildQuotationData(
const customer = storyCustomerMap.get(story.slug); const customer = storyCustomerMap.get(story.slug);
if (!opportunity || !customer) continue; if (!opportunity || !customer) continue;
const contact = primaryContactByCustomerId.get(customer.id) ?? null; const contact = primaryContactByCustomerId.get(customer.id) ?? null;
const salesperson = choose(users.sales, storyIndex); const salesperson = pickSalesUser(users, story.productTypeCode, storyIndex);
const baseDate = addDays(opportunity.createdAt, 10); const baseDate = addDays(opportunity.createdAt, 10);
const planRevisionCount = const planRevisionCount =
story.quotePlan === "returned_then_revised" story.quotePlan === "returned_then_revised"
@@ -2067,7 +2127,7 @@ function buildQuotationData(
quotations.push({ quotations.push({
id: quotationId, id: quotationId,
code: buildDocumentCode("QT", quotationSequence++), code: buildQuotationCode(story.productTypeCode, quotationSequence++),
opportunityId: opportunity.id, opportunityId: opportunity.id,
customerId: customer.id, customerId: customer.id,
contactId: contact?.id ?? null, contactId: contact?.id ?? null,
@@ -2405,7 +2465,10 @@ function buildQuotationData(
const quotationId = crypto.randomUUID(); const quotationId = crypto.randomUUID();
quotations.push({ quotations.push({
id: quotationId, id: quotationId,
code: buildDocumentCode("QT", quotationSequence++), code: buildQuotationCode(
choose(["crane", "dockdoor", "solarcell"], quotations.length),
quotationSequence++
),
opportunityId: null, opportunityId: null,
customerId: customer.id, customerId: customer.id,
contactId: contact?.id ?? null, contactId: contact?.id ?? null,

View File

@@ -0,0 +1,129 @@
import { getDefaultPermissions } from '../../lib/auth/rbac.ts';
import { createSqlClient, deterministicId, type SqlClient } from '../../../scripts/lib/seed-utils.ts';
type UserRow = {
id: string;
email: string;
};
const DEFAULT_ORGANIZATION = {
id: deterministicId('organization:alla-demo'),
name: 'ALLA Demo Organization',
slug: 'alla-demo',
membershipId: deterministicId('membership:alla-demo:super-admin')
};
async function resolveSuperAdmin(sql: SqlClient): Promise<UserRow> {
const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase();
if (!email) {
throw new Error('SUPER_ADMIN_EMAIL is required before running system organization seed.');
}
const [user] = await sql<UserRow[]>`
select id, email
from users
where email = ${email}
limit 1
`;
if (!user) {
throw new Error('Super admin user not found. Run seed-super-admin first.');
}
return user;
}
async function ensureOrganization(sql: SqlClient, superAdmin: UserRow) {
await sql`
insert into organizations (
id,
name,
slug,
plan,
created_by
) values (
${DEFAULT_ORGANIZATION.id},
${DEFAULT_ORGANIZATION.name},
${DEFAULT_ORGANIZATION.slug},
${'enterprise'},
${superAdmin.id}
)
on conflict (slug) do update
set
name = excluded.name,
plan = excluded.plan,
updated_at = now()
`;
const membershipPermissions = getDefaultPermissions('admin', 'crm_admin');
const [existingMembership] = await sql<{ id: string }[]>`
select id
from memberships
where user_id = ${superAdmin.id}
and organization_id = ${DEFAULT_ORGANIZATION.id}
limit 1
`;
if (existingMembership) {
await sql`
update memberships
set
role = ${'admin'},
business_role = ${'crm_admin'},
permissions = ${sql.array(membershipPermissions)},
updated_at = now()
where id = ${existingMembership.id}
`;
} else {
await sql`
insert into memberships (
id,
user_id,
organization_id,
role,
business_role,
permissions,
branch_scope_ids,
product_type_scope_ids
) values (
${DEFAULT_ORGANIZATION.membershipId},
${superAdmin.id},
${DEFAULT_ORGANIZATION.id},
${'admin'},
${'crm_admin'},
${sql.array(membershipPermissions)},
${sql.array([])},
${sql.array([])}
)
`;
}
await sql`
update users
set
active_organization_id = ${DEFAULT_ORGANIZATION.id},
updated_at = now()
where id = ${superAdmin.id}
`;
}
async function main() {
const sql = createSqlClient();
try {
const superAdmin = await resolveSuperAdmin(sql);
await ensureOrganization(sql, superAdmin);
console.log(`[seed:system-org] Ready ${DEFAULT_ORGANIZATION.name}`);
} finally {
await sql.end();
}
}
main().catch((error) => {
console.error(
'[seed:system-org] Failed:',
error instanceof Error ? error.message : String(error)
);
process.exitCode = 1;
});

View File

@@ -1,5 +1,11 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
const NODE_TS_RUNTIME_ARGS = [
'--disable-warning=ExperimentalWarning',
'--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
'--experimental-strip-types'
];
function run(label: string, args: string[]) { function run(label: string, args: string[]) {
const result = spawnSync(process.execPath, args, { const result = spawnSync(process.execPath, args, {
stdio: 'inherit', stdio: 'inherit',
@@ -13,7 +19,12 @@ function run(label: string, args: string[]) {
function main() { function main() {
run('super admin seed', ['scripts/seed-super-admin.js']); run('super admin seed', ['scripts/seed-super-admin.js']);
run('foundation seed', ['--experimental-strip-types', 'src/db/seeds/foundation.seed.ts']); run('system organization seed', [
...NODE_TS_RUNTIME_ARGS,
'src/db/seeds/system-organization.seed.ts'
]);
run('foundation seed', [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/foundation.seed.ts']);
run('pdf template seed', [...NODE_TS_RUNTIME_ARGS, 'scripts/reseed-pdf-template-version.ts']);
} }
main(); main();

View File

@@ -0,0 +1,431 @@
import bcrypt from 'bcryptjs';
import { getDefaultPermissions, getDefaultRoleProfiles } from '../../lib/auth/rbac.ts';
import { createSqlClient, deterministicId, type SqlClient } from '../../../scripts/lib/seed-utils.ts';
type OrganizationRow = {
id: string;
name: string;
createdBy: string;
};
type OptionRow = {
id: string;
category: string;
code: string;
};
type RoleProfileRow = {
id: string;
code: string;
};
type UatRoleAssignmentSeed = {
roleCode: string;
isPrimary: boolean;
branchScopeMode: 'all' | 'selected' | 'inherit';
branchCodes?: string[];
productTypeScopeMode: 'all' | 'selected' | 'inherit';
productTypeCodes?: string[];
};
type UatUserSeed = {
key: string;
name: string;
email: string;
password: string;
systemRole: 'super_admin' | 'user';
membershipRole: 'admin' | 'user';
businessRole:
| 'marketing'
| 'sales'
| 'sales_support'
| 'sales_manager'
| 'department_manager'
| 'top_manager'
| 'crm_admin';
roleAssignments: UatRoleAssignmentSeed[];
};
const UAT_PASSWORD = 'UatDemo123!';
const UAT_USERS: UatUserSeed[] = [
{
key: 'mk',
name: 'MK UAT',
email: 'mk.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'marketing',
roleAssignments: [
{
roleCode: 'marketing',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
}
]
},
{
key: 'sales-crane',
name: 'Sales Crane UAT',
email: 'sales.crane.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'sales',
roleAssignments: [
{
roleCode: 'sales',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'selected',
productTypeCodes: ['crane']
}
]
},
{
key: 'sales-dockdoor',
name: 'Sales Dock Door UAT',
email: 'sales.dockdoor.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'sales',
roleAssignments: [
{
roleCode: 'sales',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'selected',
productTypeCodes: ['dockdoor']
}
]
},
{
key: 'mgr-sales',
name: 'Sales Manager UAT',
email: 'mgr.sales.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'sales_manager',
roleAssignments: [
{
roleCode: 'sales_manager',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
},
{
roleCode: 'department_manager',
isPrimary: false,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
}
]
},
{
key: 'ceo',
name: 'CEO UAT',
email: 'ceo.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'user',
businessRole: 'top_manager',
roleAssignments: [
{
roleCode: 'top_manager',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
}
]
},
{
key: 'admin',
name: 'Admin UAT',
email: 'admin.uat@alla.local',
password: UAT_PASSWORD,
systemRole: 'user',
membershipRole: 'admin',
businessRole: 'crm_admin',
roleAssignments: [
{
roleCode: 'crm_admin',
isPrimary: true,
branchScopeMode: 'all',
productTypeScopeMode: 'all'
}
]
}
];
async function ensureRoleProfiles(
sql: SqlClient,
organization: OrganizationRow
): Promise<Map<string, RoleProfileRow>> {
const defaultProfiles = getDefaultRoleProfiles();
for (const profile of defaultProfiles) {
await sql`
insert into crm_role_profiles (
id,
organization_id,
code,
name,
description,
permissions,
branch_scope_mode,
product_scope_mode,
ownership_scope,
approval_authority,
is_system,
is_active,
created_by,
updated_by
) values (
${deterministicId(`crm-role-profile:${organization.id}:${profile.code}`)},
${organization.id},
${profile.code},
${profile.name},
${profile.description},
${sql.array(profile.permissions)},
${profile.branchScopeMode},
${profile.productScopeMode},
${profile.ownershipScope},
${profile.approvalAuthority},
${profile.isSystem},
${true},
${organization.createdBy},
${organization.createdBy}
)
on conflict (organization_id, code) do update
set
name = excluded.name,
description = excluded.description,
permissions = excluded.permissions,
branch_scope_mode = excluded.branch_scope_mode,
product_scope_mode = excluded.product_scope_mode,
ownership_scope = excluded.ownership_scope,
approval_authority = excluded.approval_authority,
is_system = excluded.is_system,
is_active = excluded.is_active,
updated_by = excluded.updated_by,
deleted_at = null,
updated_at = now()
`;
}
const rows = await sql<RoleProfileRow[]>`
select id, code
from crm_role_profiles
where organization_id = ${organization.id}
and deleted_at is null
`;
return new Map(rows.map((row) => [row.code, row]));
}
async function loadOptions(sql: SqlClient, organizationId: string): Promise<Map<string, OptionRow>> {
const rows = await sql<OptionRow[]>`
select id, category, code
from ms_options
where organization_id = ${organizationId}
and deleted_at is null
and is_active = true
`;
return new Map(rows.map((row) => [`${row.category}:${row.code}`, row]));
}
function resolveScopeIds(
optionMap: Map<string, OptionRow>,
category: string,
codes: string[] | undefined
): string[] {
if (!codes?.length) {
return [];
}
return codes.map((code) => {
const row = optionMap.get(`${category}:${code}`);
if (!row) {
throw new Error(`Missing ${category} option for code ${code}`);
}
return row.id;
});
}
async function upsertUatUser(
sql: SqlClient,
organization: OrganizationRow,
optionMap: Map<string, OptionRow>,
roleProfileMap: Map<string, RoleProfileRow>,
seed: UatUserSeed
) {
const passwordHash = await bcrypt.hash(seed.password, 10);
const [userRow] = await sql<{ id: string }[]>`
insert into users (
id,
name,
email,
password_hash,
system_role,
active_organization_id
) values (
${deterministicId(`uat-user:${seed.email}`)},
${seed.name},
${seed.email},
${passwordHash},
${seed.systemRole},
${organization.id}
)
on conflict (email) do update
set
name = excluded.name,
password_hash = excluded.password_hash,
system_role = excluded.system_role,
active_organization_id = excluded.active_organization_id,
updated_at = now()
returning id
`;
const membershipPermissions = getDefaultPermissions(seed.membershipRole, seed.businessRole);
const existingMembership =
(
await sql<{ id: string }[]>`
select id
from memberships
where user_id = ${userRow.id}
and organization_id = ${organization.id}
limit 1
`
)[0] ?? null;
if (existingMembership) {
await sql`
update memberships
set
role = ${seed.membershipRole},
business_role = ${seed.businessRole},
permissions = ${sql.array(membershipPermissions)},
branch_scope_ids = ${sql.array([])},
product_type_scope_ids = ${sql.array([])},
updated_at = now()
where id = ${existingMembership.id}
`;
} else {
await sql`
insert into memberships (
id,
user_id,
organization_id,
role,
business_role,
permissions,
branch_scope_ids,
product_type_scope_ids
) values (
${deterministicId(`membership:${organization.id}:${seed.email}`)},
${userRow.id},
${organization.id},
${seed.membershipRole},
${seed.businessRole},
${sql.array(membershipPermissions)},
${sql.array([])},
${sql.array([])}
)
`;
}
await sql`
delete from crm_user_role_assignments
where organization_id = ${organization.id}
and user_id = ${userRow.id}
`;
for (const assignment of seed.roleAssignments) {
const roleProfile = roleProfileMap.get(assignment.roleCode);
if (!roleProfile) {
throw new Error(`Missing CRM role profile ${assignment.roleCode}`);
}
await sql`
insert into crm_user_role_assignments (
id,
organization_id,
user_id,
role_profile_id,
branch_scope_mode,
branch_scope_ids,
product_type_scope_mode,
product_type_scope_ids,
is_primary,
is_active,
assigned_by,
assigned_at,
created_at,
updated_at,
deleted_at
) values (
${deterministicId(`crm-role-assignment:${organization.id}:${seed.email}:${assignment.roleCode}`)},
${organization.id},
${userRow.id},
${roleProfile.id},
${assignment.branchScopeMode},
${sql.array(resolveScopeIds(optionMap, 'crm_branch', assignment.branchCodes))},
${assignment.productTypeScopeMode},
${sql.array(resolveScopeIds(optionMap, 'crm_product_type', assignment.productTypeCodes))},
${assignment.isPrimary},
${true},
${organization.createdBy},
${new Date()},
${new Date()},
${new Date()},
${null}
)
`;
}
}
async function seedOrganization(sql: SqlClient, organization: OrganizationRow) {
const optionMap = await loadOptions(sql, organization.id);
const roleProfileMap = await ensureRoleProfiles(sql, organization);
for (const seed of UAT_USERS) {
await upsertUatUser(sql, organization, optionMap, roleProfileMap, seed);
}
console.log(`[seed:uat-users] ${organization.name}: ${UAT_USERS.length} UAT users ready`);
}
async function main() {
const sql = createSqlClient();
try {
const organizations = await sql<OrganizationRow[]>`
select id, name, created_by as "createdBy"
from organizations
order by name asc
`;
if (organizations.length === 0) {
throw new Error('No organizations found. Run seed:system first.');
}
for (const organization of organizations) {
await seedOrganization(sql, organization);
}
} finally {
await sql.end();
}
}
main().catch((error) => {
console.error('[seed:uat-users] Failed:', error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View File

@@ -1,17 +1,32 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
const NODE_TS_RUNTIME_ARGS = [
'--disable-warning=ExperimentalWarning',
'--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
'--experimental-strip-types'
];
function main() { function main() {
const result = spawnSync( const steps: Array<{ label: string; args: string[] }> = [
process.execPath,
['--experimental-strip-types', 'src/db/seeds/crm-uat.seed.ts'],
{ {
label: 'uat users seed',
args: [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/uat-users.seed.ts']
},
{
label: 'crm uat data seed',
args: [...NODE_TS_RUNTIME_ARGS, 'src/db/seeds/crm-uat.seed.ts']
}
];
for (const step of steps) {
const result = spawnSync(process.execPath, step.args, {
stdio: 'inherit', stdio: 'inherit',
env: process.env env: process.env
} });
);
if (result.status !== 0) { if (result.status !== 0) {
throw new Error(`[seed:uat] failed with exit code ${result.status ?? 1}`); throw new Error(`[seed:uat] ${step.label} failed with exit code ${result.status ?? 1}`);
}
} }
} }

View File

@@ -18,7 +18,7 @@ const signInSchema = z.object({
export default function SignInViewPage() { export default function SignInViewPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"; const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard/crm";
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const form = useAppForm({ const form = useAppForm({

View File

@@ -610,7 +610,7 @@ async function canAccessCustomerRow(
accessContext, accessContext,
)); ));
if (accessContext.ownershipScope === "monitor") { if (accessContext.ownershipScope === 'monitor') {
return opportunityCustomerIds.has(row.id); return opportunityCustomerIds.has(row.id);
} }

View File

@@ -1,29 +1,39 @@
import { auth } from '@/auth'; import { auth } from "@/auth";
import { NextResponse } from 'next/server'; import { NextResponse } from "next/server";
const protectedApiPrefixes = ['/api/products', '/api/organizations', '/api/users']; const protectedApiPrefixes = [
const superAdminOnlyRoutes = ['/dashboard/workspaces']; "/api/products",
const organizationAdminRoutes = ['/dashboard/users', '/dashboard/workspaces/team']; "/api/organizations",
"/api/users",
];
const superAdminOnlyRoutes = ["/dashboard/workspaces"];
const organizationAdminRoutes = [
"/dashboard/users",
"/dashboard/workspaces/team",
];
export default auth((req) => { export default auth((req) => {
const pathname = req.nextUrl.pathname; const pathname = req.nextUrl.pathname;
const isDashboardRoute = pathname.startsWith('/dashboard'); const isDashboardRoute = pathname.startsWith("/dashboard/crm");
const isProtectedApiRoute = protectedApiPrefixes.some((prefix) => pathname.startsWith(prefix)); const isProtectedApiRoute = protectedApiPrefixes.some((prefix) =>
pathname.startsWith(prefix),
);
const user = req.auth?.user; const user = req.auth?.user;
const isSignedIn = !!user; const isSignedIn = !!user;
const isSuperAdmin = user?.systemRole === 'super_admin'; const isSuperAdmin = user?.systemRole === "super_admin";
const isOrganizationAdmin = const isOrganizationAdmin =
isSuperAdmin || isSuperAdmin ||
(user?.activeOrganizationId && (user?.activeOrganizationId &&
(user.activeMembershipRole === 'admin' || user.activePermissions?.includes('users:manage'))); (user.activeMembershipRole === "admin" ||
user.activePermissions?.includes("users:manage")));
if (!isSignedIn && (isDashboardRoute || isProtectedApiRoute)) { if (!isSignedIn && (isDashboardRoute || isProtectedApiRoute)) {
if (isProtectedApiRoute) { if (isProtectedApiRoute) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
} }
const signInUrl = new URL('/auth/sign-in', req.nextUrl.origin); const signInUrl = new URL("/auth/sign-in", req.nextUrl.origin);
signInUrl.searchParams.set('callbackUrl', pathname); signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl); return NextResponse.redirect(signInUrl);
} }
@@ -32,7 +42,7 @@ export default auth((req) => {
superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) && superAdminOnlyRoutes.some((prefix) => pathname.startsWith(prefix)) &&
!isSuperAdmin !isSuperAdmin
) { ) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin)); return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
} }
if ( if (
@@ -40,7 +50,7 @@ export default auth((req) => {
organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) && organizationAdminRoutes.some((prefix) => pathname.startsWith(prefix)) &&
!isOrganizationAdmin !isOrganizationAdmin
) { ) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin)); return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
} }
return NextResponse.next(); return NextResponse.next();
@@ -48,7 +58,7 @@ export default auth((req) => {
export const config = { export const config = {
matcher: [ matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
'/(api|trpc)(.*)' "/(api|trpc)(.*)",
] ],
}; };