d.6
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"audit": "payload-parity",
|
"audit": "payload-parity",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1108",
|
"quotationCode": "CRA2606-1107",
|
||||||
"previewHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26",
|
"previewHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6",
|
||||||
"generationHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26",
|
"generationHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6",
|
||||||
"snapshotHash": "8f93479501b66dfd397c742447b010eaf427697429bfcfd523cd127d06106f26",
|
"snapshotHash": "dd0c565a00c6161d66e3286380ca41eec17a2eee1f183236644733141cd6c1e6",
|
||||||
"mismatches": [],
|
"mismatches": [],
|
||||||
"comparableFieldCount": 31,
|
"comparableFieldCount": 31,
|
||||||
"topicInputKeyCount": 6,
|
"topicInputKeyCount": 6,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"audit": "runtime-payload",
|
"audit": "runtime-payload",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"staticLabelsStatus": "PASS",
|
"staticLabelsStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1108",
|
"quotationCode": "CRA2606-1107",
|
||||||
"templateName": "ALLA Demo Quotation Standard",
|
"templateName": "ALLA Demo Quotation Standard",
|
||||||
"templateVersion": "1.0",
|
"templateVersion": "1.0",
|
||||||
"findingCount": 168,
|
"findingCount": 168,
|
||||||
@@ -25,28 +25,28 @@
|
|||||||
"field": "documentData.customer.id",
|
"field": "documentData.customer.id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "cf2c1f29-ce0a-41dd-acc0-13b7f82086cd",
|
"value": "a476b13f-87f5-47dc-9606-e88ec24cda87",
|
||||||
"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": "Bang Sue, Bangkok",
|
"value": "Mueang Chiang Mai, Chiang Mai",
|
||||||
"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-400827",
|
"value": "02-400824",
|
||||||
"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": "7c88a108-419f-47ed-a62b-1d784a7b8455",
|
"value": "78c92957-126f-4765-873f-14ce01330be9",
|
||||||
"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": "0810000250",
|
"value": "0810000220",
|
||||||
"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": "Procurement Manager",
|
"value": "Plant 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": "0bbb3fce-f3a2-408a-b93a-6faaa4d74f55",
|
"value": "c0a3c490-30ae-4c77-8f80-ec9328eeb66e",
|
||||||
"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": "CRA2606-1108",
|
"value": "CRA2606-1107",
|
||||||
"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": "Material Handling Lab Crane",
|
"value": "Dealer Stock Crane Package",
|
||||||
"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": "Bang Sue Engineering Faculty",
|
"value": "Chiang Mai Service Warehouse",
|
||||||
"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:university-lab-crane-approved",
|
"value": "crm-uat-seed-v1:dealer-channel-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": 3008000,
|
"value": 4418000,
|
||||||
"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": 3122003,
|
"value": 4585442,
|
||||||
"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": "f37f7d99-f1e8-4aa9-a207-0a280ec74e26",
|
"value": "7ffd6a54-7c9f-4ab2-b6b2-7a38a0f680e9",
|
||||||
"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 Material Handling Lab Crane",
|
"value": "Main equipment supply for Dealer Stock Crane Package",
|
||||||
"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": 1717102,
|
"value": 2521993,
|
||||||
"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": 1717102,
|
"value": 2521993,
|
||||||
"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": "63809391-7dd6-4ebd-8aba-3cb5d18b148a",
|
"value": "02c15bda-11ae-4e5c-a0f4-a5943214abec",
|
||||||
"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 Material Handling Lab Crane",
|
"value": "Installation and commissioning for Dealer Stock Crane Package",
|
||||||
"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": 874161,
|
"value": 1283924,
|
||||||
"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": 874161,
|
"value": 1283924,
|
||||||
"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": "6e304b78-bbb6-4beb-b04e-6958a9e3ca69",
|
"value": "d13a4d65-7b45-46d5-a2d1-2f79db82779b",
|
||||||
"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 Material Handling Lab Crane",
|
"value": "Operator training and documentation for Dealer Stock Crane Package",
|
||||||
"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": 312200,
|
"value": 458544,
|
||||||
"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": 312200,
|
"value": 458544,
|
||||||
"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": "e28c0f75-086f-4d4a-bc81-a3292fce97b2",
|
"value": "970d78d6-939f-4856-a17f-3538983f149a",
|
||||||
"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 Material Handling Lab Crane",
|
"value": "Project management and testing for Dealer Stock Crane Package",
|
||||||
"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": 218540,
|
"value": 320981,
|
||||||
"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": 218540,
|
"value": 320981,
|
||||||
"message": "Scalar value is populated"
|
"message": "Scalar value is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -403,14 +403,14 @@
|
|||||||
"field": "documentData.quotationCustomers[0].id",
|
"field": "documentData.quotationCustomers[0].id",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "3eb300e9-8410-4a97-b2f3-2fb935e60a0b",
|
"value": "40ef3613-a862-4166-be00-b79237e80a68",
|
||||||
"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": "cf2c1f29-ce0a-41dd-acc0-13b7f82086cd",
|
"value": "a476b13f-87f5-47dc-9606-e88ec24cda87",
|
||||||
"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": "3013a775-bc86-4b22-90f9-048fa1225a91",
|
"value": "7fdc1ec7-fd3b-479c-8688-913c4e54329b",
|
||||||
"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 material handling lab crane according to approved drawing.",
|
"value": "Supply and execute dealer stock crane package 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": "bf614822-2e02-456e-af35-c9dc50b0140f",
|
"value": "921523e3-c2a5-4be1-8d10-a2a4c2913176",
|
||||||
"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": "eee9e6f9-15d5-4540-9cce-628026bc0281",
|
"value": "b3138a62-36c3-4ffb-9504-08479aa3d263",
|
||||||
"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": "bf614822-2e02-456e-af35-c9dc50b0140f",
|
"value": "921523e3-c2a5-4be1-8d10-a2a4c2913176",
|
||||||
"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 15, 2026",
|
"value": "Jun 3, 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 3,008,000.00",
|
"value": "THB 4,418,000.00",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -816,70 +816,70 @@
|
|||||||
"field": "documentData.signatures.preparedBy.actedAt",
|
"field": "documentData.signatures.preparedBy.actedAt",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "2026-06-15T09:00:00.000Z",
|
"value": "2026-06-03T09: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": "-",
|
"value": "Sales Manager UAT",
|
||||||
"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": "-",
|
"value": "Sales Manager",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "documentData.signatures.approvedBy.actedAt",
|
"field": "documentData.signatures.approvedBy.actedAt",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "WARNING",
|
"status": "PASS",
|
||||||
"value": null,
|
"value": "2026-06-06T10:00:00.000Z",
|
||||||
"message": "Value is null"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "documentData.signatures.authorizedBy.name",
|
"field": "documentData.signatures.authorizedBy.name",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "Sales Manager UAT",
|
"value": "CEO UAT",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "documentData.signatures.authorizedBy.position",
|
"field": "documentData.signatures.authorizedBy.position",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "Sales Manager",
|
"value": "General Manager",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "documentData.signatures.authorizedBy.actedAt",
|
"field": "documentData.signatures.authorizedBy.actedAt",
|
||||||
"source": "documentData",
|
"source": "documentData",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "2026-06-17T11:00:00.000Z",
|
"value": "2026-06-07T09: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": "Bang Sue, Bangkok",
|
"value": "Mueang Chiang Mai, Chiang Mai",
|
||||||
"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-400827",
|
"value": "02-400824",
|
||||||
"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": "Material Handling Lab Crane",
|
"value": "Dealer Stock Crane Package",
|
||||||
"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": "Bang Sue Engineering Faculty",
|
"value": "Chiang Mai Service Warehouse",
|
||||||
"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 15, 2026",
|
"value": "Jun 3, 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": "CRA2606-1108",
|
"value": "CRA2606-1107",
|
||||||
"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 3,008,000.00",
|
"value": "THB 4,418,000.00",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -998,35 +998,35 @@
|
|||||||
"field": "templateInput.app2",
|
"field": "templateInput.app2",
|
||||||
"source": "templateInput",
|
"source": "templateInput",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "-",
|
"value": "Sales Manager UAT",
|
||||||
"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": "-",
|
"value": "Sales Manager",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "templateInput.app3",
|
"field": "templateInput.app3",
|
||||||
"source": "templateInput",
|
"source": "templateInput",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "Sales Manager UAT",
|
"value": "CEO UAT",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "templateInput.app3_position",
|
"field": "templateInput.app3_position",
|
||||||
"source": "templateInput",
|
"source": "templateInput",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "Sales Manager",
|
"value": "General Manager",
|
||||||
"message": "String is populated"
|
"message": "String is populated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"field": "templateInput.quotation_date",
|
"field": "templateInput.quotation_date",
|
||||||
"source": "templateInput",
|
"source": "templateInput",
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"value": "Jun 15, 2026",
|
"value": "Jun 3, 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": "CRA2606-1108",
|
"value": "CRA2606-1107",
|
||||||
"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": "฿3,008,000.00",
|
"value": "฿4,418,000.00",
|
||||||
"message": "Price table amount cell is formatted"
|
"message": "Price table amount cell is formatted"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"audit": "approved-snapshot-parity",
|
"audit": "approved-snapshot-parity",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1108",
|
"quotationCode": "CRA2606-1107",
|
||||||
"approvedSnapshotAvailable": true,
|
"approvedSnapshotAvailable": true,
|
||||||
"approvedTemplateVersionId": "e7e89bf2-f107-4f90-b03d-4a7533883d84",
|
"approvedTemplateVersionId": "e7e89bf2-f107-4f90-b03d-4a7533883d84",
|
||||||
"computedTemplateVersionId": "e7e89bf2-f107-4f90-b03d-4a7533883d84",
|
"computedTemplateVersionId": "e7e89bf2-f107-4f90-b03d-4a7533883d84",
|
||||||
"inconsistentTemplateKeys": [],
|
"inconsistentTemplateKeys": [],
|
||||||
"computedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
"computedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8",
|
||||||
"storedSignatureHash": "922ab2a823f98b032b17da281c86c3a2780d27cca73a60a7b983d14ad039b229",
|
"storedSignatureHash": "4106a8d9883d207231103e13f29aff692e6ad1d81a0e61cb26cea7ceea73e3b8",
|
||||||
"approvedArtifactReference": null,
|
"approvedArtifactReference": "artifact:7088f4e9-c56a-4389-975b-3c7ccf13c772",
|
||||||
"refreshedFixtureSnapshot": true,
|
"refreshedFixtureSnapshot": true,
|
||||||
"usedAuditFixture": false
|
"usedAuditFixture": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-06-30T16:18:58.393Z",
|
"generatedAt": "2026-06-30T23:54:34.472Z",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1108",
|
"quotationCode": "CRA2606-1107",
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"templateVersionReport": "template-version-report.json",
|
"templateVersionReport": "template-version-report.json",
|
||||||
"placeholderIntegrityReport": "placeholder-integrity-report.json",
|
"placeholderIntegrityReport": "placeholder-integrity-report.json",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PDF Integrity Summary
|
# PDF Integrity Summary
|
||||||
|
|
||||||
- Generated At: 2026-06-30T16:18:58.393Z
|
- Generated At: 2026-06-30T23:54:34.472Z
|
||||||
- Overall Status: PASS
|
- Overall Status: PASS
|
||||||
- Quotation Code: CRA2606-1108
|
- Quotation Code: CRA2606-1107
|
||||||
|
|
||||||
## Statuses
|
## Statuses
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"audit": "template-version-integrity",
|
"audit": "template-version-integrity",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"activeTemplateCount": 1,
|
"activeTemplateCount": 1,
|
||||||
"quotationCode": "CRA2606-1108",
|
"quotationCode": "CRA2606-1107",
|
||||||
"approvedTemplateVersionId": null,
|
"approvedTemplateVersionId": null,
|
||||||
"approvedTemplateVersionIsActive": false,
|
"approvedTemplateVersionIsActive": false,
|
||||||
"issues": []
|
"issues": []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"audit": "topic-runtime",
|
"audit": "topic-runtime",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1108",
|
"quotationCode": "CRA2606-1107",
|
||||||
"topicCount": 3,
|
"topicCount": 3,
|
||||||
"topicInputKeys": [
|
"topicInputKeys": [
|
||||||
"topic_1_0",
|
"topic_1_0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-06-30T16:18:57.428Z",
|
"generatedAt": "2026-06-30T23:54:26.582Z",
|
||||||
"overallStatus": "PASS",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1108",
|
"quotationCode": "CRA2606-1107",
|
||||||
"expectedFixtureCode": "QT-H5-AUDIT",
|
"expectedFixtureCode": "QT-H5-AUDIT",
|
||||||
"template": {
|
"template": {
|
||||||
"templateName": "ALLA Demo Quotation Standard",
|
"templateName": "ALLA Demo Quotation Standard",
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
"consistency": {
|
"consistency": {
|
||||||
"status": "PASS",
|
"status": "PASS",
|
||||||
"approvedSnapshotAvailable": true,
|
"approvedSnapshotAvailable": true,
|
||||||
"approvedArtifactReference": null,
|
"approvedArtifactReference": "artifact:7088f4e9-c56a-4389-975b-3c7ccf13c772",
|
||||||
"inconsistentKeys": []
|
"inconsistentKeys": []
|
||||||
},
|
},
|
||||||
"integritySummary": {
|
"integritySummary": {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PDF Audit Report
|
# PDF Audit Report
|
||||||
|
|
||||||
- Generated At: 2026-06-30T16:18:57.428Z
|
- Generated At: 2026-06-30T23:54:26.582Z
|
||||||
- Overall Status: PASS
|
- Overall Status: PASS
|
||||||
- Quotation Code: CRA2606-1108
|
- Quotation Code: CRA2606-1107
|
||||||
- Template: ALLA Demo Quotation Standard v1.0
|
- Template: ALLA Demo Quotation Standard v1.0
|
||||||
- Previous Version: None
|
- Previous Version: None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Task D.6 Implementation Report - 2026-07-01
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Separate CRM business `Activity` semantics from system-generated audit history.
|
||||||
|
- Rename CRM detail tabs that currently show system history from `Activity` to `Audit Log`.
|
||||||
|
- Improve follow-up date input UX with the shared calendar picker and stable `YYYY-MM-DD` display.
|
||||||
|
- Introduce a contract-first CRM Activity foundation without migrating existing follow-up storage in this phase.
|
||||||
|
|
||||||
|
## Review Summary
|
||||||
|
Reviewed before implementation:
|
||||||
|
- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md)
|
||||||
|
- [plans/task-d.6.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-d.6.md)
|
||||||
|
- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md)
|
||||||
|
- [docs/standards/architecture-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/architecture-rules.md)
|
||||||
|
- [docs/standards/ui-ux-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/ui-ux-rules.md)
|
||||||
|
- [docs/standards/task-review-checklist.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-review-checklist.md)
|
||||||
|
- Related CRM detail, follow-up, and audit-log foundations under `src/features/crm/**` and `src/features/foundation/audit-log/**`
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- Added shared [audit-log-tab.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/components/audit-log-tab.tsx) and [types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/audit-log/types.ts) for consistent CRM audit history rendering.
|
||||||
|
- Updated lead, opportunity, customer, and quotation detail views to label system history as `Audit Log`.
|
||||||
|
- Enriched CRM audit-log records in lead, opportunity, customer, and quotation services so UI can consume `beforeData`, `afterData`, and `requestId`.
|
||||||
|
- Standardized [crm-form-controls.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/components/crm-form-controls.tsx) and [date-picker-field.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/components/forms/fields/date-picker-field.tsx) to show `YYYY-MM-DD` and allow clearing optional dates.
|
||||||
|
- Rebuilt [lead-followup-panel.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/lead-followup-panel.tsx) to use the shared date-picker field instead of plain text date entry.
|
||||||
|
- Added [src/features/crm/activity/types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/activity/types.ts) to define future activity entity scope, types, statuses, priorities, permission contracts, and overdue-status derivation.
|
||||||
|
- Added activity and audit-log permission keys in [rbac.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `npm run typecheck` -> PASS
|
||||||
|
- `npm run build` -> PASS
|
||||||
|
- `npm run audit:pdf` -> PASS
|
||||||
|
|
||||||
|
## Known Follow-up
|
||||||
|
- Historical lead/opportunity/quotation follow-up rows still live in their existing tables and APIs; only the UX and activity contracts were prepared in this phase.
|
||||||
|
- `npm run build` still emits pre-existing Turbopack warnings about workspace root lockfile detection and NFT tracing around PDF audit/template validation paths. No new blocking build issue was introduced by D.6.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Task D.6 now reserves `Activity` for future MK/Sales business work, presents current system history as `Audit Log`, improves lead follow-up date entry with a proper calendar picker, and establishes the first reusable contracts for a future CRM activity module.
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -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.
|
||||||
|
|||||||
127
plans/task-d.6.1.md
Normal file
127
plans/task-d.6.1.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Task D.6.1 – Lead Create Form UX & Default Rules
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
ปรับหน้า Create Lead ให้เหมาะกับการสร้าง Lead ใหม่จริง โดยซ่อน field ที่ยังไม่ควรใช้ตอนเริ่มต้น และตั้งค่า default ตาม business rule
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### 1. Hide fields on Create Lead
|
||||||
|
|
||||||
|
ตอนสร้าง Lead ใหม่ ไม่ต้องแสดง field ต่อไปนี้:
|
||||||
|
|
||||||
|
* Lost Reason
|
||||||
|
* Status
|
||||||
|
* Marketing Owner
|
||||||
|
|
||||||
|
Field เหล่านี้อาจแสดงในหน้า Edit/Detail ตาม permission และสถานะภายหลังได้
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Default Lead Status
|
||||||
|
|
||||||
|
เมื่อสร้าง Lead ใหม่ ให้ระบบตั้งสถานะอัตโนมัติเป็น:
|
||||||
|
|
||||||
|
```text
|
||||||
|
new_job
|
||||||
|
```
|
||||||
|
|
||||||
|
หรือ label:
|
||||||
|
|
||||||
|
```text
|
||||||
|
New Job
|
||||||
|
```
|
||||||
|
|
||||||
|
User ไม่ต้องเลือกเองในหน้า Create
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Default Marketing Owner
|
||||||
|
|
||||||
|
Marketing Owner ต้อง default เป็นผู้สร้างเอกสาร
|
||||||
|
|
||||||
|
```text
|
||||||
|
marketingOwnerId = currentUser.id
|
||||||
|
```
|
||||||
|
|
||||||
|
ไม่ต้องให้ user เลือกตอน Create
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Add Lead Source field
|
||||||
|
|
||||||
|
เพิ่ม field:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Lead Source
|
||||||
|
```
|
||||||
|
|
||||||
|
ใช้เก็บที่มาของ Lead เช่น:
|
||||||
|
|
||||||
|
* Website
|
||||||
|
* Facebook
|
||||||
|
* Line OA
|
||||||
|
* Phone Call
|
||||||
|
* Walk-in
|
||||||
|
* Referral
|
||||||
|
* Existing Customer
|
||||||
|
* Exhibition
|
||||||
|
* Sales Visit
|
||||||
|
* Other
|
||||||
|
|
||||||
|
ควรใช้ master option / ms_options ถ้ามี foundation รองรับอยู่แล้ว
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Create vs Edit Behavior
|
||||||
|
|
||||||
|
### Create Lead
|
||||||
|
|
||||||
|
แสดงเฉพาะ field ที่จำเป็นสำหรับเปิด Lead ใหม่
|
||||||
|
|
||||||
|
ไม่แสดง:
|
||||||
|
|
||||||
|
* Lost Reason
|
||||||
|
* Status
|
||||||
|
* Marketing Owner
|
||||||
|
|
||||||
|
ระบบกำหนดให้อัตโนมัติ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edit Lead / Detail Lead
|
||||||
|
|
||||||
|
สามารถแสดง field เพิ่มเติมตามสถานะและ permission เช่น:
|
||||||
|
|
||||||
|
* Status
|
||||||
|
* Lost Reason เมื่อ status เป็น lost/closed lost
|
||||||
|
* Marketing Owner สำหรับ admin/manager ถ้าต้อง reassign
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Rules
|
||||||
|
|
||||||
|
ฝั่ง API/service ต้อง enforce ด้วย ไม่พึ่ง UI อย่างเดียว:
|
||||||
|
|
||||||
|
* ถ้า create lead ไม่มี status → set `new_job`
|
||||||
|
* ถ้า create lead ไม่มี marketingOwnerId → set current user
|
||||||
|
* ห้าม user override marketingOwnerId ถ้าไม่มี permission
|
||||||
|
* Lost Reason ต้องไม่ required ตอน create
|
||||||
|
* Lead Source ควรถูก validate จาก master option
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
* Create Lead ไม่แสดง Lost Reason
|
||||||
|
* Create Lead ไม่แสดง Status
|
||||||
|
* Create Lead ไม่แสดง Marketing Owner
|
||||||
|
* Lead ใหม่ถูกสร้างด้วย status `New Job`
|
||||||
|
* Marketing Owner เป็น user ที่สร้าง Lead
|
||||||
|
* มี field Lead Source
|
||||||
|
* Lead Source บันทึกได้ถูกต้อง
|
||||||
|
* Edit/Detail ยังรองรับ status/lost reason ตาม business rule
|
||||||
|
* `npm run typecheck`
|
||||||
|
* `npm run build`
|
||||||
642
plans/task-d.6.md
Normal file
642
plans/task-d.6.md
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
# Task D.6 – CRM Activity Foundation & Audit Log Separation
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Separate system audit history from real CRM business activities.
|
||||||
|
|
||||||
|
Currently, several CRM document detail pages use the tab name `Activity` for system-generated history. This is semantically incorrect.
|
||||||
|
|
||||||
|
In CRM, `Activity` should mean actionable work performed by MK/Sales, such as:
|
||||||
|
|
||||||
|
* site visit
|
||||||
|
* customer meeting
|
||||||
|
* phone call
|
||||||
|
* follow-up
|
||||||
|
* requirement discussion
|
||||||
|
* site survey
|
||||||
|
* quotation follow-up
|
||||||
|
|
||||||
|
System-generated history should be displayed under `Audit Log`.
|
||||||
|
|
||||||
|
This task also improves follow-up date input UX by replacing plain date text input with a calendar date picker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Background
|
||||||
|
|
||||||
|
Current issue:
|
||||||
|
|
||||||
|
```text id="b2lkfk"
|
||||||
|
Activity tab
|
||||||
|
```
|
||||||
|
|
||||||
|
is being used for system history such as:
|
||||||
|
|
||||||
|
* created
|
||||||
|
* updated
|
||||||
|
* status changed
|
||||||
|
* approved
|
||||||
|
* rejected
|
||||||
|
* PDF generated
|
||||||
|
* customer package generated
|
||||||
|
|
||||||
|
But business users expect Activity to mean:
|
||||||
|
|
||||||
|
```text id="f8lcxc"
|
||||||
|
2026-07-01: Visit customer site
|
||||||
|
|
||||||
|
2026-07-03: Follow up quotation by phone
|
||||||
|
|
||||||
|
2026-07-05: Meeting with purchasing team
|
||||||
|
```
|
||||||
|
|
||||||
|
Therefore:
|
||||||
|
|
||||||
|
* System history = Audit Log
|
||||||
|
* Business work = Activity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope
|
||||||
|
|
||||||
|
Included:
|
||||||
|
|
||||||
|
* Rename system history tab from `Activity` to `Audit Log`
|
||||||
|
* Standardize Audit Log tab across CRM documents
|
||||||
|
* Create CRM Activity foundation model
|
||||||
|
* Prepare Activity UI patterns
|
||||||
|
* Improve follow-up date input to use calendar picker
|
||||||
|
* Keep existing follow-up business behavior compatible
|
||||||
|
* Prepare future Calendar integration
|
||||||
|
|
||||||
|
Excluded:
|
||||||
|
|
||||||
|
* Full calendar module implementation
|
||||||
|
* Google Calendar / Outlook sync
|
||||||
|
* Notification automation
|
||||||
|
* Advanced recurring activity scheduling
|
||||||
|
* Mobile push reminders
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Discovery Requirement
|
||||||
|
|
||||||
|
Before implementation, inspect the actual codebase.
|
||||||
|
|
||||||
|
Review at minimum:
|
||||||
|
|
||||||
|
```text id="7scakw"
|
||||||
|
Quotation detail tabs
|
||||||
|
Lead detail tabs
|
||||||
|
Opportunity detail tabs
|
||||||
|
Customer detail tabs
|
||||||
|
Follow-up components
|
||||||
|
Audit log components
|
||||||
|
Existing activity/follow-up tables
|
||||||
|
Existing calendar/date picker components
|
||||||
|
Existing shadcn calendar/popover/date-input patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
Identify:
|
||||||
|
|
||||||
|
* where `Activity` is used as audit history
|
||||||
|
* where follow-up forms use plain date input
|
||||||
|
* which modules already have follow-up data
|
||||||
|
* which audit log API/components can be reused
|
||||||
|
* current date format standards
|
||||||
|
|
||||||
|
Do not assume the current UI structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Terminology Rules
|
||||||
|
|
||||||
|
## Audit Log
|
||||||
|
|
||||||
|
Means system-generated immutable history.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text id="zg8qvr"
|
||||||
|
Quotation Created
|
||||||
|
|
||||||
|
Status Changed
|
||||||
|
|
||||||
|
Approval Requested
|
||||||
|
|
||||||
|
Approved
|
||||||
|
|
||||||
|
Rejected
|
||||||
|
|
||||||
|
PDF Generated
|
||||||
|
|
||||||
|
Customer Package Generated
|
||||||
|
|
||||||
|
Email Sent
|
||||||
|
```
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
* read-only
|
||||||
|
* system generated
|
||||||
|
* immutable
|
||||||
|
* audit/compliance focused
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activity
|
||||||
|
|
||||||
|
Means CRM business work.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text id="g7k2rv"
|
||||||
|
Visit customer site
|
||||||
|
|
||||||
|
Call customer
|
||||||
|
|
||||||
|
Follow-up quotation
|
||||||
|
|
||||||
|
Site survey
|
||||||
|
|
||||||
|
Requirement gathering
|
||||||
|
|
||||||
|
Meeting purchasing team
|
||||||
|
|
||||||
|
Send revised offer
|
||||||
|
```
|
||||||
|
|
||||||
|
Characteristics:
|
||||||
|
|
||||||
|
* created by user
|
||||||
|
* assigned to user
|
||||||
|
* has due date
|
||||||
|
* may be planned or completed
|
||||||
|
* can appear in calendar
|
||||||
|
* may trigger notification/reminder later
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 1 - Audit Log Tab Refactor
|
||||||
|
|
||||||
|
Rename all tabs that currently show system history from:
|
||||||
|
|
||||||
|
```text id="q1j1zt"
|
||||||
|
Activity
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```text id="bmkkw7"
|
||||||
|
Audit Log
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply at minimum to:
|
||||||
|
|
||||||
|
* Quotation detail
|
||||||
|
* Lead detail
|
||||||
|
* Opportunity detail
|
||||||
|
* Customer detail
|
||||||
|
* Any CRM document detail page that uses Activity for history
|
||||||
|
|
||||||
|
If a page already has true Activity content, do not rename it blindly. Split it into:
|
||||||
|
|
||||||
|
```text id="mfooo0"
|
||||||
|
Activity
|
||||||
|
|
||||||
|
Audit Log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Audit Log UI Standard
|
||||||
|
|
||||||
|
Create or reuse a shared component:
|
||||||
|
|
||||||
|
```text id="tk4rn3"
|
||||||
|
<AuditLogTab />
|
||||||
|
```
|
||||||
|
|
||||||
|
Display:
|
||||||
|
|
||||||
|
* timestamp
|
||||||
|
* actor
|
||||||
|
* action
|
||||||
|
* before/after state
|
||||||
|
* remarks
|
||||||
|
* related artifact
|
||||||
|
* system-generated badge if applicable
|
||||||
|
|
||||||
|
Sort:
|
||||||
|
|
||||||
|
```text id="0or6f8"
|
||||||
|
newest first
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not allow editing or deleting audit rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 2 - CRM Activity Foundation
|
||||||
|
|
||||||
|
Design a reusable CRM Activity model.
|
||||||
|
|
||||||
|
Activities should support multiple CRM entities:
|
||||||
|
|
||||||
|
* Lead
|
||||||
|
* Opportunity
|
||||||
|
* Quotation
|
||||||
|
* Customer
|
||||||
|
* Contact
|
||||||
|
* Future PO / Service Report
|
||||||
|
|
||||||
|
Suggested fields:
|
||||||
|
|
||||||
|
```text id="svj0zq"
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
entityType
|
||||||
|
entityId
|
||||||
|
activityType
|
||||||
|
subject
|
||||||
|
description
|
||||||
|
ownerId
|
||||||
|
assignedToId
|
||||||
|
dueDate
|
||||||
|
dueTime
|
||||||
|
completedAt
|
||||||
|
status
|
||||||
|
priority
|
||||||
|
outcome
|
||||||
|
nextAction
|
||||||
|
createdBy
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Activity Types
|
||||||
|
|
||||||
|
Initial activity types:
|
||||||
|
|
||||||
|
```text id="zmet5e"
|
||||||
|
site_visit
|
||||||
|
meeting
|
||||||
|
phone_call
|
||||||
|
email_follow_up
|
||||||
|
quotation_follow_up
|
||||||
|
site_survey
|
||||||
|
requirement_discussion
|
||||||
|
internal_task
|
||||||
|
other
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Activity Status
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```text id="8cmuir"
|
||||||
|
planned
|
||||||
|
in_progress
|
||||||
|
completed
|
||||||
|
cancelled
|
||||||
|
overdue
|
||||||
|
```
|
||||||
|
|
||||||
|
Overdue may be derived, not necessarily stored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Activity UI
|
||||||
|
|
||||||
|
For each supported CRM entity, Activity tab should eventually show:
|
||||||
|
|
||||||
|
```text id="bn6gr5"
|
||||||
|
Upcoming Activities
|
||||||
|
|
||||||
|
Completed Activities
|
||||||
|
|
||||||
|
Overdue Activities
|
||||||
|
```
|
||||||
|
|
||||||
|
Each activity card should show:
|
||||||
|
|
||||||
|
* activity type
|
||||||
|
* subject
|
||||||
|
* due date
|
||||||
|
* owner
|
||||||
|
* status
|
||||||
|
* priority
|
||||||
|
* related document
|
||||||
|
* outcome if completed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 3 - Follow-up Date Picker Improvement
|
||||||
|
|
||||||
|
Replace plain follow-up date input with a calendar date picker.
|
||||||
|
|
||||||
|
Apply to existing follow-up forms in:
|
||||||
|
|
||||||
|
* Lead follow-up
|
||||||
|
* Opportunity follow-up
|
||||||
|
* Quotation follow-up
|
||||||
|
* Customer follow-up if present
|
||||||
|
|
||||||
|
Date input must be easy for users to click and choose.
|
||||||
|
|
||||||
|
Use existing UI components if available:
|
||||||
|
|
||||||
|
```text id="s1f84i"
|
||||||
|
Calendar
|
||||||
|
Popover
|
||||||
|
Button
|
||||||
|
FormControl
|
||||||
|
```
|
||||||
|
|
||||||
|
Preferred UX:
|
||||||
|
|
||||||
|
```text id="t9fzwi"
|
||||||
|
[ Select follow-up date 📅 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
On click:
|
||||||
|
|
||||||
|
```text id="far25j"
|
||||||
|
Calendar opens
|
||||||
|
User selects date
|
||||||
|
Date displays as YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Date Format Rule
|
||||||
|
|
||||||
|
Display dates as:
|
||||||
|
|
||||||
|
```text id="srzquj"
|
||||||
|
YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
Storage should use existing backend-compatible date format.
|
||||||
|
|
||||||
|
Do not introduce locale-dependent rendering that may cause hydration mismatch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Follow-up UX Rules
|
||||||
|
|
||||||
|
* Calendar picker must support keyboard navigation.
|
||||||
|
* Date must be clear after selection.
|
||||||
|
* Existing validation must remain.
|
||||||
|
* Required date rules must remain.
|
||||||
|
* User should not need to manually type dates.
|
||||||
|
* Manual typing may be disabled unless existing component requires it.
|
||||||
|
* If clearing date is allowed, provide a clear button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 4 - Compatibility With Existing Follow-up
|
||||||
|
|
||||||
|
Do not break existing follow-up APIs.
|
||||||
|
|
||||||
|
If current follow-up is stored separately from the new Activity model, keep it compatible.
|
||||||
|
|
||||||
|
Recommended strategy:
|
||||||
|
|
||||||
|
```text id="xyx67z"
|
||||||
|
Phase D.6
|
||||||
|
|
||||||
|
Rename Audit Log tab
|
||||||
|
|
||||||
|
Improve follow-up date picker
|
||||||
|
|
||||||
|
Introduce Activity foundation
|
||||||
|
|
||||||
|
Do not migrate all follow-ups yet
|
||||||
|
```
|
||||||
|
|
||||||
|
Future task can migrate:
|
||||||
|
|
||||||
|
```text id="543zj8"
|
||||||
|
Follow-up
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
Activity
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 5 - Calendar Readiness
|
||||||
|
|
||||||
|
Activities must be designed so future calendar integration can use them.
|
||||||
|
|
||||||
|
Required future-ready fields:
|
||||||
|
|
||||||
|
* dueDate
|
||||||
|
* dueTime
|
||||||
|
* assignedToId
|
||||||
|
* ownerId
|
||||||
|
* status
|
||||||
|
* entityType
|
||||||
|
* entityId
|
||||||
|
|
||||||
|
This prepares future integration with:
|
||||||
|
|
||||||
|
```text id="ksqzgi"
|
||||||
|
Calendar module
|
||||||
|
|
||||||
|
Notifications
|
||||||
|
|
||||||
|
Reminders
|
||||||
|
|
||||||
|
Team visibility
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
|
||||||
|
Add or reuse permissions:
|
||||||
|
|
||||||
|
```text id="kujab3"
|
||||||
|
crmActivityRead
|
||||||
|
crmActivityCreate
|
||||||
|
crmActivityUpdate
|
||||||
|
crmActivityComplete
|
||||||
|
crmActivityDelete
|
||||||
|
crmAuditLogRead
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit log should be read-only.
|
||||||
|
|
||||||
|
Activity should follow CRM visibility rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Audit Logging
|
||||||
|
|
||||||
|
Creating, updating, completing, or cancelling an Activity should write audit logs.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text id="kqf2c1"
|
||||||
|
Activity Created
|
||||||
|
|
||||||
|
Activity Completed
|
||||||
|
|
||||||
|
Activity Cancelled
|
||||||
|
|
||||||
|
Activity Rescheduled
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing Requirements
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash id="a4tuy5"
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
npm run audit:pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
Add tests where practical for:
|
||||||
|
|
||||||
|
* tab label transformation
|
||||||
|
* date picker value formatting
|
||||||
|
* activity status helpers
|
||||||
|
* audit log display helpers
|
||||||
|
* follow-up date conversion to YYYY-MM-DD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Manual Verification Scenarios
|
||||||
|
|
||||||
|
## Scenario 1 - Audit Log Tab
|
||||||
|
|
||||||
|
Open quotation detail.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text id="1sabcz"
|
||||||
|
Activity tab is replaced by Audit Log
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit rows still show correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 2 - True Activity
|
||||||
|
|
||||||
|
Create or inspect Activity tab where implemented.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text id="ofkfzg"
|
||||||
|
Shows business tasks, not system logs
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text id="jzae6e"
|
||||||
|
2026-07-01 Visit customer site
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 3 - Follow-up Date Picker
|
||||||
|
|
||||||
|
Open follow-up form.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
* Date picker opens on click.
|
||||||
|
* User can select date.
|
||||||
|
* Selected date displays as YYYY-MM-DD.
|
||||||
|
* Submitted payload remains compatible with existing API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 4 - No Hydration Issue
|
||||||
|
|
||||||
|
Reload detail page.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
* Date displays consistently as YYYY-MM-DD.
|
||||||
|
* No React hydration warning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
* System history tabs are renamed to `Audit Log`.
|
||||||
|
* Audit Log is read-only.
|
||||||
|
* Activity is reserved for business tasks and follow-ups.
|
||||||
|
* CRM Activity foundation exists or is planned with schema/service contracts.
|
||||||
|
* Follow-up date input uses a calendar date picker.
|
||||||
|
* Selected date displays as YYYY-MM-DD.
|
||||||
|
* Existing follow-up APIs remain compatible.
|
||||||
|
* Existing audit log data still renders correctly.
|
||||||
|
* Build, typecheck, and PDF audit pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Out of Scope
|
||||||
|
|
||||||
|
## Calendar Phase
|
||||||
|
|
||||||
|
* Full calendar module
|
||||||
|
* Team calendar
|
||||||
|
* Manager calendar
|
||||||
|
* Drag-and-drop schedule
|
||||||
|
* Reminder automation
|
||||||
|
|
||||||
|
## Notification Phase
|
||||||
|
|
||||||
|
* Activity reminder email
|
||||||
|
* Follow-up due notification
|
||||||
|
* Overdue notification
|
||||||
|
|
||||||
|
## Migration Phase
|
||||||
|
|
||||||
|
* Full migration of historical follow-up rows into Activity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Final Success Condition
|
||||||
|
|
||||||
|
Task D.6 is complete when CRM system history is consistently shown as `Audit Log`, CRM `Activity` is clearly reserved for MK/Sales business work, and follow-up date entry is improved with a calendar picker using stable `YYYY-MM-DD` formatting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implementation Result - 2026-07-01
|
||||||
|
|
||||||
|
Status: Completed
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
- Renamed system-history tabs from `Activity` to `Audit Log` in quotation, lead, opportunity, and customer detail views.
|
||||||
|
- Added shared `AuditLogTab` UI and reused it across CRM detail surfaces to present immutable audit entries consistently.
|
||||||
|
- Extended CRM audit-log records with `beforeData`, `afterData`, and `requestId` so the UI can show richer system history.
|
||||||
|
- Standardized date-picker placeholders to `YYYY-MM-DD` and added clear support in `CrmDateInput`.
|
||||||
|
- Migrated lead follow-up creation UI from plain text date entry to the shared calendar date-picker pattern.
|
||||||
|
- Added CRM Activity foundation contracts in `src/features/crm/activity/types.ts` for entity scope, activity types, statuses, priorities, derived overdue status, and future-ready permissions.
|
||||||
|
- Added permission keys for `crmActivityRead`, `crmActivityCreate`, `crmActivityUpdate`, `crmActivityComplete`, `crmActivityDelete`, and `crmAuditLogRead`.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `npm run typecheck` -> PASS
|
||||||
|
- `npm run build` -> PASS
|
||||||
|
- `npm run audit:pdf` -> PASS
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `npm run build` still reports existing Turbopack warnings about workspace root lockfile detection and an NFT trace warning from PDF audit/template validation paths. Build still completed successfully.
|
||||||
|
- This task intentionally does not migrate historical follow-up rows into the new activity model yet; follow-ups remain API-compatible and activity contracts are prepared for a later phase.
|
||||||
|
|
||||||
|
Implementation report:
|
||||||
|
- [task-d.6-crm-activity-foundation-audit-log-separation-2026-07-01.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-d.6-crm-activity-foundation-audit-log-separation-2026-07-01.md)
|
||||||
@@ -23,7 +23,7 @@ export function DatePickerField({
|
|||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
required,
|
required,
|
||||||
placeholder = 'DD/MM/YYYY',
|
placeholder = 'YYYY-MM-DD',
|
||||||
disabled
|
disabled
|
||||||
}: DatePickerFieldProps) {
|
}: DatePickerFieldProps) {
|
||||||
void disabled;
|
void disabled;
|
||||||
|
|||||||
86
src/features/crm/activity/types.ts
Normal file
86
src/features/crm/activity/types.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
export const CRM_ACTIVITY_ENTITY_TYPES = [
|
||||||
|
'lead',
|
||||||
|
'opportunity',
|
||||||
|
'quotation',
|
||||||
|
'customer',
|
||||||
|
'contact',
|
||||||
|
'purchase_order',
|
||||||
|
'service_report'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const CRM_ACTIVITY_TYPES = [
|
||||||
|
'site_visit',
|
||||||
|
'meeting',
|
||||||
|
'phone_call',
|
||||||
|
'email_follow_up',
|
||||||
|
'quotation_follow_up',
|
||||||
|
'site_survey',
|
||||||
|
'requirement_discussion',
|
||||||
|
'internal_task',
|
||||||
|
'other'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const CRM_ACTIVITY_STATUSES = [
|
||||||
|
'planned',
|
||||||
|
'in_progress',
|
||||||
|
'completed',
|
||||||
|
'cancelled',
|
||||||
|
'overdue'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const CRM_ACTIVITY_PRIORITIES = ['low', 'medium', 'high', 'urgent'] as const;
|
||||||
|
|
||||||
|
export type CrmActivityEntityType = (typeof CRM_ACTIVITY_ENTITY_TYPES)[number];
|
||||||
|
export type CrmActivityType = (typeof CRM_ACTIVITY_TYPES)[number];
|
||||||
|
export type CrmActivityStatus = (typeof CRM_ACTIVITY_STATUSES)[number];
|
||||||
|
export type CrmActivityPriority = (typeof CRM_ACTIVITY_PRIORITIES)[number];
|
||||||
|
|
||||||
|
export interface CrmActivityRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
entityType: CrmActivityEntityType;
|
||||||
|
entityId: string;
|
||||||
|
activityType: CrmActivityType;
|
||||||
|
subject: string;
|
||||||
|
description: string | null;
|
||||||
|
ownerId: string | null;
|
||||||
|
assignedToId: string | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
dueTime: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
status: Exclude<CrmActivityStatus, 'overdue'>;
|
||||||
|
priority: CrmActivityPriority;
|
||||||
|
outcome: string | null;
|
||||||
|
nextAction: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CRM_ACTIVITY_PERMISSIONS = {
|
||||||
|
read: PERMISSIONS.crmActivityRead,
|
||||||
|
create: PERMISSIONS.crmActivityCreate,
|
||||||
|
update: PERMISSIONS.crmActivityUpdate,
|
||||||
|
complete: PERMISSIONS.crmActivityComplete,
|
||||||
|
delete: PERMISSIONS.crmActivityDelete,
|
||||||
|
auditLogRead: PERMISSIONS.crmAuditLogRead
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function resolveCrmActivityStatus(input: {
|
||||||
|
status: Exclude<CrmActivityStatus, 'overdue'>;
|
||||||
|
dueDate?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
now?: Date;
|
||||||
|
}): CrmActivityStatus {
|
||||||
|
if (input.completedAt || input.status === 'completed' || input.status === 'cancelled') {
|
||||||
|
return input.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.dueDate) {
|
||||||
|
return input.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = (input.now ?? new Date()).toISOString().slice(0, 10);
|
||||||
|
return input.dueDate < today ? 'overdue' : input.status;
|
||||||
|
}
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
12
src/features/crm/audit-log/types.ts
Normal file
12
src/features/crm/audit-log/types.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export interface CrmAuditLogRecord {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
createdAt: string;
|
||||||
|
userId: string;
|
||||||
|
actorName: string | null;
|
||||||
|
beforeData: unknown;
|
||||||
|
afterData: unknown;
|
||||||
|
requestId: string | null;
|
||||||
|
}
|
||||||
119
src/features/crm/components/audit-log-tab.tsx
Normal file
119
src/features/crm/components/audit-log-tab.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||||
|
|
||||||
|
function formatActionLabel(action: string) {
|
||||||
|
return action.replaceAll('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntityLabel(entityType: string) {
|
||||||
|
return entityType.replace(/^crm_/, '').replaceAll('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuditValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditPayloadBlock({
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: unknown;
|
||||||
|
}) {
|
||||||
|
const normalizedValue = normalizeAuditValue(value);
|
||||||
|
|
||||||
|
if (!normalizedValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-2 rounded-lg border bg-muted/30 p-3'>
|
||||||
|
<div className='text-muted-foreground text-xs font-medium'>{label}</div>
|
||||||
|
<pre className='overflow-x-auto whitespace-pre-wrap break-words text-xs'>
|
||||||
|
{normalizedValue}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLogTab({
|
||||||
|
title = 'Audit Log',
|
||||||
|
description = 'System-generated immutable history for this CRM record.',
|
||||||
|
emptyMessage = 'No audit log recorded yet.',
|
||||||
|
items,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
items: CrmAuditLogRecord[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='flex flex-col gap-4'>
|
||||||
|
{!items.length ? (
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((item, index) => (
|
||||||
|
<div key={item.id} className='flex flex-col gap-4'>
|
||||||
|
<div className='flex flex-col gap-3 rounded-lg border p-4'>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<Badge variant='outline' className='capitalize'>
|
||||||
|
{formatActionLabel(item.action)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='secondary' className='capitalize'>
|
||||||
|
{formatEntityLabel(item.entityType)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='secondary'>System</Badge>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-1 text-sm'>
|
||||||
|
<div className='font-medium'>{item.actorName ?? item.userId}</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
{formatDateTime(item.createdAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-3',
|
||||||
|
normalizeAuditValue(item.beforeData) && normalizeAuditValue(item.afterData)
|
||||||
|
? 'md:grid-cols-2'
|
||||||
|
: 'md:grid-cols-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AuditPayloadBlock label='Before' value={item.beforeData} />
|
||||||
|
<AuditPayloadBlock label='After' value={item.afterData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{index < items.length - 1 ? <Separator /> : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,41 +50,50 @@ function toDateValue(date?: Date) {
|
|||||||
export function CrmDateInput({
|
export function CrmDateInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'DD/MM/YYYY',
|
placeholder = 'YYYY-MM-DD',
|
||||||
className
|
className,
|
||||||
|
allowClear = true
|
||||||
}: {
|
}: {
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
allowClear?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const selectedDate = parseDateValue(value);
|
const selectedDate = parseDateValue(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
<PopoverTrigger asChild>
|
<Popover>
|
||||||
<Button
|
<PopoverTrigger asChild>
|
||||||
type='button'
|
<Button
|
||||||
variant='outline'
|
type='button'
|
||||||
className={cn(
|
variant='outline'
|
||||||
'w-full justify-start overflow-hidden text-left font-normal',
|
className={cn(
|
||||||
!value && 'text-muted-foreground',
|
'min-w-0 flex-1 justify-start overflow-hidden text-left font-normal',
|
||||||
className
|
!value && 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icons.calendar className='mr-2 h-4 w-4 shrink-0' />
|
<Icons.calendar className='mr-2 h-4 w-4 shrink-0' />
|
||||||
<span className='truncate'>{value ? formatCrmDate(value) : placeholder}</span>
|
<span className='truncate'>{value ? formatCrmDate(value) : placeholder}</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className='w-auto p-0' align='start'>
|
||||||
|
<Calendar
|
||||||
|
mode='single'
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={(date) => onChange(toDateValue(date))}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{allowClear && value ? (
|
||||||
|
<Button type='button' variant='outline' size='icon' onClick={() => onChange('')}>
|
||||||
|
<Icons.close />
|
||||||
|
<span className='sr-only'>Clear date</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
) : null}
|
||||||
<PopoverContent className='w-auto p-0' align='start'>
|
</div>
|
||||||
<Calendar
|
|
||||||
mode='single'
|
|
||||||
selected={selectedDate}
|
|
||||||
onSelect={(date) => onChange(toDateValue(date))}
|
|
||||||
initialFocus
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,15 +111,7 @@ export interface CustomerContactRecord {
|
|||||||
shares: ContactShareRecord[];
|
shares: ContactShareRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerActivityRecord {
|
export type CustomerActivityRecord = CrmAuditLogRecord;
|
||||||
id: string;
|
|
||||||
action: string;
|
|
||||||
entityType: string;
|
|
||||||
entityId: string;
|
|
||||||
createdAt: string;
|
|
||||||
userId: string;
|
|
||||||
actorName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomerRelatedOpportunityItem {
|
export interface CustomerRelatedOpportunityItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -234,3 +226,4 @@ export interface MutationSuccessResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||||
import { formatDateTime } from '@/lib/format';
|
import { formatDateTime } from '@/lib/format';
|
||||||
import { formatNumber } from '@/lib/number-format';
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import { customerByIdOptions } from '../api/queries';
|
import { customerByIdOptions } from '../api/queries';
|
||||||
@@ -129,7 +130,7 @@ export function CustomerDetail({
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value='overview'>ภาพรวม</TabsTrigger>
|
<TabsTrigger value='overview'>ภาพรวม</TabsTrigger>
|
||||||
{canReadContacts ? <TabsTrigger value='contacts'>ผู้ติดต่อ</TabsTrigger> : null}
|
{canReadContacts ? <TabsTrigger value='contacts'>ผู้ติดต่อ</TabsTrigger> : null}
|
||||||
<TabsTrigger value='activity'>กิจกรรม</TabsTrigger>
|
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
|
||||||
<TabsTrigger value='related'>เอกสารที่เกี่ยวข้อง</TabsTrigger>
|
<TabsTrigger value='related'>เอกสารที่เกี่ยวข้อง</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value='overview'>
|
<TabsContent value='overview'>
|
||||||
@@ -194,41 +195,11 @@ export function CustomerDetail({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
) : null}
|
) : null}
|
||||||
<TabsContent value='activity'>
|
<TabsContent value='activity'>
|
||||||
<Card>
|
<AuditLogTab
|
||||||
<CardHeader>
|
items={data.activity}
|
||||||
<CardTitle>กิจกรรม</CardTitle>
|
emptyMessage='No audit log recorded yet.'
|
||||||
<CardDescription>ประวัติการเปลี่ยนแปลงที่เกิดจากการแก้ไขข้อมูลลูกค้า</CardDescription>
|
description='System-generated immutable history for this customer.'
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent className='space-y-4'>
|
|
||||||
{!data.activity.length ? (
|
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
|
||||||
ยังไม่มีกิจกรรม
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data.activity.map((item, index) => (
|
|
||||||
<div key={item.id} className='space-y-4'>
|
|
||||||
<div className='flex items-start gap-3'>
|
|
||||||
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Badge variant='outline' className='capitalize'>
|
|
||||||
{item.action}
|
|
||||||
</Badge>
|
|
||||||
<span className='text-sm font-medium'>
|
|
||||||
{item.actorName ?? item.userId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='text-muted-foreground text-xs'>
|
|
||||||
{formatDateTime(item.createdAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{index < data.activity.length - 1 ? <Separator /> : null}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value='related'>
|
<TabsContent value='related'>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1033,6 +1033,9 @@ export async function getCustomerActivity(
|
|||||||
createdAt: log.createdAt,
|
createdAt: log.createdAt,
|
||||||
userId: log.userId,
|
userId: log.userId,
|
||||||
actorName: actorMap.get(log.userId) ?? null,
|
actorName: actorMap.get(log.userId) ?? null,
|
||||||
|
beforeData: log.beforeData,
|
||||||
|
afterData: log.afterData,
|
||||||
|
requestId: log.requestId,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||||
import { formatDateTime } from '@/lib/date-format';
|
import { formatDateTime } from '@/lib/date-format';
|
||||||
import { formatNumber } from '@/lib/number-format';
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import { leadByIdOptions } from '../api/queries';
|
import { leadByIdOptions } from '../api/queries';
|
||||||
@@ -111,7 +112,7 @@ export function LeadDetail({
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||||
<TabsTrigger value='linked-opportunities'>Linked Opportunities</TabsTrigger>
|
<TabsTrigger value='linked-opportunities'>Linked Opportunities</TabsTrigger>
|
||||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value='followups'>
|
<TabsContent value='followups'>
|
||||||
@@ -152,26 +153,11 @@ export function LeadDetail({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='activity'>
|
<TabsContent value='activity'>
|
||||||
<Card>
|
<AuditLogTab
|
||||||
<CardHeader>
|
items={lead.activity}
|
||||||
<CardTitle>Activity</CardTitle>
|
emptyMessage='No audit log recorded yet.'
|
||||||
</CardHeader>
|
description='System-generated immutable history for this lead.'
|
||||||
<CardContent className='space-y-3'>
|
/>
|
||||||
{lead.activity.length === 0 ? (
|
|
||||||
<div className='text-muted-foreground text-sm'>No activity yet.</div>
|
|
||||||
) : (
|
|
||||||
lead.activity.map((activity) => (
|
|
||||||
<div key={activity.id} className='border-border rounded-md border p-3'>
|
|
||||||
<div className='font-medium'>{activity.action}</div>
|
|
||||||
<div className='text-muted-foreground mt-1 text-xs'>
|
|
||||||
{activity.actorName ?? activity.userId} -{' '}
|
|
||||||
{formatDateTime(activity.createdAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ export function LeadFollowupPanel({
|
|||||||
leadId: string;
|
leadId: string;
|
||||||
detail: LeadDetailResponse;
|
detail: LeadDetailResponse;
|
||||||
}) {
|
}) {
|
||||||
const { FormTextField, FormSelectField, FormTextareaField } = useFormFields<FollowupFormValues>();
|
const { FormDatePickerField, FormSelectField, FormTextareaField } =
|
||||||
|
useFormFields<FollowupFormValues>();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
...createLeadFollowupMutation,
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unable to save lead follow-up');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -42,80 +50,78 @@ export function LeadFollowupPanel({
|
|||||||
nextFollowupDate: value.nextFollowupDate || null
|
nextFollowupDate: value.nextFollowupDate || null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
toast.success('Saved lead follow-up successfully');
|
||||||
});
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
...createLeadFollowupMutation,
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('บันทึก follow-up สำเร็จ');
|
|
||||||
form.reset();
|
form.reset();
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถบันทึก follow-up ได้');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<Card>
|
||||||
<Card>
|
<CardHeader>
|
||||||
<CardHeader>
|
<CardTitle>Follow-ups</CardTitle>
|
||||||
<CardTitle>New Follow-up</CardTitle>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent className='flex flex-col gap-6'>
|
||||||
<CardContent>
|
<form.AppForm>
|
||||||
<form.AppForm>
|
<form.Form id='lead-followup-form' className='grid gap-4 md:grid-cols-2'>
|
||||||
<form.Form id='lead-followup-form' className='grid gap-4 md:grid-cols-2'>
|
<FormDatePickerField
|
||||||
<FormTextField name='followupDate' label='Follow-up Date' placeholder='YYYY-MM-DD' />
|
name='followupDate'
|
||||||
<FormTextField
|
label='Follow-up Date'
|
||||||
name='nextFollowupDate'
|
required
|
||||||
label='Next Follow-up Date'
|
placeholder='YYYY-MM-DD'
|
||||||
placeholder='YYYY-MM-DD'
|
/>
|
||||||
|
<FormDatePickerField
|
||||||
|
name='nextFollowupDate'
|
||||||
|
label='Next Follow-up Date'
|
||||||
|
placeholder='YYYY-MM-DD'
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='followupStatus'
|
||||||
|
label='Follow-up Status'
|
||||||
|
options={detail.referenceData.followupStatuses.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormTextareaField
|
||||||
|
name='note'
|
||||||
|
label='Note'
|
||||||
|
size='md'
|
||||||
|
placeholder='Summarize the latest customer conversation and next step.'
|
||||||
/>
|
/>
|
||||||
<FormSelectField
|
</div>
|
||||||
name='followupStatus'
|
</form.Form>
|
||||||
label='Follow-up Status'
|
</form.AppForm>
|
||||||
options={detail.referenceData.followupStatuses.map((item) => ({
|
|
||||||
value: item.id,
|
|
||||||
label: item.label
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<div />
|
|
||||||
<FormTextareaField name='note' label='Note' className='md:col-span-2' />
|
|
||||||
<div className='md:col-span-2'>
|
|
||||||
<Button type='submit' form='lead-followup-form' isLoading={mutation.isPending}>
|
|
||||||
Save Follow-up
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form.Form>
|
|
||||||
</form.AppForm>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<div className='flex justify-end'>
|
||||||
<CardHeader>
|
<Button type='submit' form='lead-followup-form' isLoading={mutation.isPending}>
|
||||||
<CardTitle>History</CardTitle>
|
Save Follow-up
|
||||||
</CardHeader>
|
</Button>
|
||||||
<CardContent className='space-y-3'>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-3'>
|
||||||
{detail.followups.length === 0 ? (
|
{detail.followups.length === 0 ? (
|
||||||
<div className='text-muted-foreground text-sm'>No follow-up history yet.</div>
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
No follow-ups yet.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
detail.followups.map((followup) => (
|
detail.followups.map((followup) => (
|
||||||
<div key={followup.id} className='border-border rounded-md border p-3'>
|
<div key={followup.id} className='rounded-lg border p-4'>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='font-medium'>{followup.followupStatusLabel ?? followup.followupStatus}</div>
|
||||||
<div className='font-medium'>
|
<div className='text-muted-foreground mt-1 text-xs'>
|
||||||
{followup.followupStatusLabel ?? followup.followupStatus}
|
{formatDate(followup.followupDate)}
|
||||||
|
</div>
|
||||||
|
<div className='mt-2 text-sm'>{followup.note || '-'}</div>
|
||||||
|
{followup.nextFollowupDate ? (
|
||||||
|
<div className='text-muted-foreground mt-2 text-xs'>
|
||||||
|
Next: {formatDate(followup.nextFollowupDate)}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground text-xs'>{formatDate(followup.followupDate)}</div>
|
) : null}
|
||||||
</div>
|
|
||||||
{followup.note ? <div className='mt-2 text-sm'>{followup.note}</div> : null}
|
|
||||||
<div className='text-muted-foreground mt-2 text-xs'>
|
|
||||||
{followup.createdByName ?? followup.createdBy}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,10 @@ async function listLeadActivity(
|
|||||||
entityId: log.entityId,
|
entityId: log.entityId,
|
||||||
createdAt: log.createdAt,
|
createdAt: log.createdAt,
|
||||||
userId: log.userId,
|
userId: log.userId,
|
||||||
actorName: actorMap.get(log.userId) ?? null
|
actorName: actorMap.get(log.userId) ?? null,
|
||||||
|
beforeData: log.beforeData,
|
||||||
|
afterData: log.afterData,
|
||||||
|
requestId: log.requestId
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1077,4 +1080,3 @@ export function buildLeadAccessContext(input: {
|
|||||||
}) {
|
}) {
|
||||||
return buildCrmSecurityContext(input);
|
return buildCrmSecurityContext(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,15 +47,7 @@ export interface LeadLinkedOpportunitySummary {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeadActivityRecord {
|
export type LeadActivityRecord = CrmAuditLogRecord;
|
||||||
id: string;
|
|
||||||
action: string;
|
|
||||||
entityType: string;
|
|
||||||
entityId: string;
|
|
||||||
createdAt: string;
|
|
||||||
userId: string;
|
|
||||||
actorName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeadReferenceData {
|
export interface LeadReferenceData {
|
||||||
awarenesses: LeadOption[];
|
awarenesses: LeadOption[];
|
||||||
@@ -225,4 +217,5 @@ export interface LeadMutationSuccessResponse {
|
|||||||
lead?: LeadDetail;
|
lead?: LeadDetail;
|
||||||
followup?: LeadFollowupSummary;
|
followup?: LeadFollowupSummary;
|
||||||
}
|
}
|
||||||
|
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||||
|
|
||||||
|
|||||||
@@ -166,15 +166,7 @@ export interface OpportunityFollowupRecord {
|
|||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpportunityActivityRecord {
|
export type OpportunityActivityRecord = CrmAuditLogRecord;
|
||||||
id: string;
|
|
||||||
action: string;
|
|
||||||
entityType: string;
|
|
||||||
entityId: string;
|
|
||||||
createdAt: string;
|
|
||||||
userId: string;
|
|
||||||
actorName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpportunityReferenceData {
|
export interface OpportunityReferenceData {
|
||||||
branches: OpportunityBranchOption[];
|
branches: OpportunityBranchOption[];
|
||||||
@@ -337,3 +329,4 @@ export interface MutationSuccessResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||||
import { formatDate, formatDateTime } from '@/lib/date-format';
|
import { formatDate, formatDateTime } from '@/lib/date-format';
|
||||||
import { formatNumber } from '@/lib/number-format';
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import type {
|
import type {
|
||||||
@@ -284,7 +285,7 @@ export function OpportunityDetail({
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value='followups'>Follow-up Timeline</TabsTrigger>
|
<TabsTrigger value='followups'>Follow-up Timeline</TabsTrigger>
|
||||||
<TabsTrigger value='quotations'>Linked Quotations</TabsTrigger>
|
<TabsTrigger value='quotations'>Linked Quotations</TabsTrigger>
|
||||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value='followups'>
|
<TabsContent value='followups'>
|
||||||
@@ -331,26 +332,11 @@ export function OpportunityDetail({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='activity'>
|
<TabsContent value='activity'>
|
||||||
<Card>
|
<AuditLogTab
|
||||||
<CardHeader>
|
items={data.activity}
|
||||||
<CardTitle>Activity</CardTitle>
|
emptyMessage='No audit log recorded yet.'
|
||||||
</CardHeader>
|
description='System-generated immutable history for this opportunity and its follow-ups.'
|
||||||
<CardContent className='space-y-3'>
|
/>
|
||||||
{data.activity.length === 0 ? (
|
|
||||||
<div className='text-muted-foreground text-sm'>No activity yet.</div>
|
|
||||||
) : (
|
|
||||||
data.activity.map((activity) => (
|
|
||||||
<div key={activity.id} className='border-border rounded-md border p-3'>
|
|
||||||
<div className='font-medium'>{activity.action}</div>
|
|
||||||
<div className='text-muted-foreground mt-1 text-xs'>
|
|
||||||
{activity.actorName ?? activity.userId} -{' '}
|
|
||||||
{formatDateTime(activity.createdAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1136,7 +1136,10 @@ export async function getOpportunityActivity(
|
|||||||
entityId: log.entityId,
|
entityId: log.entityId,
|
||||||
createdAt: log.createdAt,
|
createdAt: log.createdAt,
|
||||||
userId: log.userId,
|
userId: log.userId,
|
||||||
actorName: actorMap.get(log.userId) ?? null
|
actorName: actorMap.get(log.userId) ?? null,
|
||||||
|
beforeData: log.beforeData,
|
||||||
|
afterData: log.afterData,
|
||||||
|
requestId: log.requestId
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -257,15 +257,7 @@ export interface QuotationAttachmentRecord {
|
|||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotationActivityRecord {
|
export type QuotationActivityRecord = CrmAuditLogRecord;
|
||||||
id: string;
|
|
||||||
action: string;
|
|
||||||
entityType: string;
|
|
||||||
entityId: string;
|
|
||||||
createdAt: string;
|
|
||||||
userId: string;
|
|
||||||
actorName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuotationRelationItem {
|
export interface QuotationRelationItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -481,3 +473,4 @@ export interface QuotationCustomerPackageGenerateResponse {
|
|||||||
pageCount: number;
|
pageCount: number;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
SelectValue
|
SelectValue
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { AuditLogTab } from '@/features/crm/components/audit-log-tab';
|
||||||
import { formatDate, formatDateTime } from '@/lib/date-format';
|
import { formatDate, formatDateTime } from '@/lib/date-format';
|
||||||
import { formatNumber } from '@/lib/number-format';
|
import { formatNumber } from '@/lib/number-format';
|
||||||
import {
|
import {
|
||||||
@@ -1231,7 +1232,7 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
|||||||
<TabsTrigger value='topics'>Topics</TabsTrigger>
|
<TabsTrigger value='topics'>Topics</TabsTrigger>
|
||||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||||
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
|
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
|
||||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
<TabsTrigger value='activity'>Audit Log</TabsTrigger>
|
||||||
<TabsTrigger value='approval'>Approval</TabsTrigger>
|
<TabsTrigger value='approval'>Approval</TabsTrigger>
|
||||||
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -1657,45 +1658,12 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='activity'>
|
<TabsContent value='activity'>
|
||||||
<Card>
|
<AuditLogTab
|
||||||
<CardHeader>
|
items={data.activity}
|
||||||
<CardTitle>Activity</CardTitle>
|
emptyMessage='No audit log recorded yet.'
|
||||||
<CardDescription>
|
description='System-generated immutable history for this quotation and its child records.'
|
||||||
Audit events for quotation mutations and child records.
|
/>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className='space-y-4'>
|
|
||||||
{!data.activity.length ? (
|
|
||||||
<EmptyState message='No activity recorded yet.' />
|
|
||||||
) : (
|
|
||||||
data.activity.map((item, index) => (
|
|
||||||
<div key={item.id} className='space-y-4'>
|
|
||||||
<div className='flex items-start gap-3'>
|
|
||||||
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Badge variant='outline' className='capitalize'>
|
|
||||||
{item.action}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant='secondary'>
|
|
||||||
{item.entityType.replaceAll('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
<span className='text-sm font-medium'>
|
|
||||||
{item.actorName ?? item.userId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='text-muted-foreground text-xs'>
|
|
||||||
{formatDateTime(item.createdAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{index < data.activity.length - 1 ? <Separator /> : null}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='approval'>
|
<TabsContent value='approval'>
|
||||||
|
|||||||
@@ -1444,7 +1444,10 @@ export async function getQuotationActivity(
|
|||||||
entityId: log.entityId,
|
entityId: log.entityId,
|
||||||
createdAt: log.createdAt,
|
createdAt: log.createdAt,
|
||||||
userId: log.userId,
|
userId: log.userId,
|
||||||
actorName: actorMap.get(log.userId) ?? null
|
actorName: actorMap.get(log.userId) ?? null,
|
||||||
|
beforeData: log.beforeData,
|
||||||
|
afterData: log.afterData,
|
||||||
|
requestId: log.requestId
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ export const PERMISSIONS = {
|
|||||||
crmLeadRead: 'crm.lead.read',
|
crmLeadRead: 'crm.lead.read',
|
||||||
crmLeadCreate: 'crm.lead.create',
|
crmLeadCreate: 'crm.lead.create',
|
||||||
crmLeadUpdate: 'crm.lead.update',
|
crmLeadUpdate: 'crm.lead.update',
|
||||||
|
crmActivityRead: 'crm.activity.read',
|
||||||
|
crmActivityCreate: 'crm.activity.create',
|
||||||
|
crmActivityUpdate: 'crm.activity.update',
|
||||||
|
crmActivityComplete: 'crm.activity.complete',
|
||||||
|
crmActivityDelete: 'crm.activity.delete',
|
||||||
|
crmAuditLogRead: 'crm.audit_log.read',
|
||||||
crmLeadAssign: 'crm.lead.assign',
|
crmLeadAssign: 'crm.lead.assign',
|
||||||
crmLeadDelete: 'crm.lead.delete',
|
crmLeadDelete: 'crm.lead.delete',
|
||||||
crmCustomerRead: 'crm.customer.read',
|
crmCustomerRead: 'crm.customer.read',
|
||||||
@@ -513,6 +519,18 @@ PERMISSIONS.crmDocumentTemplateRead,
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
||||||
|
{
|
||||||
|
key: 'activity',
|
||||||
|
label: 'Activities & Audit',
|
||||||
|
permissions: [
|
||||||
|
{ key: PERMISSIONS.crmActivityRead, label: 'Read activities' },
|
||||||
|
{ key: PERMISSIONS.crmActivityCreate, label: 'Create activities' },
|
||||||
|
{ key: PERMISSIONS.crmActivityUpdate, label: 'Update activities' },
|
||||||
|
{ key: PERMISSIONS.crmActivityComplete, label: 'Complete activities' },
|
||||||
|
{ key: PERMISSIONS.crmActivityDelete, label: 'Delete activities' },
|
||||||
|
{ key: PERMISSIONS.crmAuditLogRead, label: 'Read audit logs' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'lead',
|
key: 'lead',
|
||||||
label: 'Leads',
|
label: 'Leads',
|
||||||
|
|||||||
Reference in New Issue
Block a user