From 8148850fda68dabc4c72a8453633ab36b12d6fd0 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 15 Jun 2026 13:20:39 +0700 Subject: [PATCH] tsk-C compleate --- .../implementation/task-c-customer-contact.md | 105 ++ drizzle/0002_plain_anthem.sql | 52 + drizzle/meta/0002_snapshot.json | 978 ++++++++++++++++++ drizzle/meta/_journal.json | 9 +- plans/task-c.md | 372 +++++++ plans/task-d.md | 409 ++++++++ .../[id]/contacts/[contactId]/route.ts | 119 +++ .../api/crm/customers/[id]/contacts/route.ts | 86 ++ src/app/api/crm/customers/[id]/route.ts | 134 +++ src/app/api/crm/customers/route.ts | 104 ++ src/app/dashboard/crm/customers/[id]/page.tsx | 79 +- src/app/dashboard/crm/customers/page.tsx | 77 +- src/db/schema.ts | 60 ++ src/db/seeds/foundation.seed.ts | 5 + src/features/crm/customers/api/mutations.ts | 74 ++ src/features/crm/customers/api/queries.ts | 28 + src/features/crm/customers/api/service.ts | 81 ++ src/features/crm/customers/api/types.ts | 161 +++ .../components/contact-form-sheet.tsx | 164 +++ .../components/customer-cell-action.tsx | 82 ++ .../customers/components/customer-columns.tsx | 138 +++ .../components/customer-contacts-tab.tsx | 136 +++ .../customers/components/customer-detail.tsx | 255 +++++ .../components/customer-form-sheet.tsx | 250 +++++ .../customers/components/customer-listing.tsx | 42 + .../components/customer-status-badge.tsx | 27 + .../customers/components/customers-table.tsx | 66 ++ .../crm/customers/schemas/customer.schema.ts | 45 + src/features/crm/customers/server/service.ts | 646 ++++++++++++ src/features/foundation/audit-log/service.ts | 21 +- src/features/foundation/audit-log/types.ts | 7 + src/lib/auth/rbac.ts | 22 +- src/lib/searchparams.ts | 1 + 33 files changed, 4800 insertions(+), 35 deletions(-) create mode 100644 docs/implementation/task-c-customer-contact.md create mode 100644 drizzle/0002_plain_anthem.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 plans/task-c.md create mode 100644 plans/task-d.md create mode 100644 src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts create mode 100644 src/app/api/crm/customers/[id]/contacts/route.ts create mode 100644 src/app/api/crm/customers/[id]/route.ts create mode 100644 src/app/api/crm/customers/route.ts create mode 100644 src/features/crm/customers/api/mutations.ts create mode 100644 src/features/crm/customers/api/queries.ts create mode 100644 src/features/crm/customers/api/service.ts create mode 100644 src/features/crm/customers/api/types.ts create mode 100644 src/features/crm/customers/components/contact-form-sheet.tsx create mode 100644 src/features/crm/customers/components/customer-cell-action.tsx create mode 100644 src/features/crm/customers/components/customer-columns.tsx create mode 100644 src/features/crm/customers/components/customer-contacts-tab.tsx create mode 100644 src/features/crm/customers/components/customer-detail.tsx create mode 100644 src/features/crm/customers/components/customer-form-sheet.tsx create mode 100644 src/features/crm/customers/components/customer-listing.tsx create mode 100644 src/features/crm/customers/components/customer-status-badge.tsx create mode 100644 src/features/crm/customers/components/customers-table.tsx create mode 100644 src/features/crm/customers/schemas/customer.schema.ts create mode 100644 src/features/crm/customers/server/service.ts diff --git a/docs/implementation/task-c-customer-contact.md b/docs/implementation/task-c-customer-contact.md new file mode 100644 index 0000000..9f83461 --- /dev/null +++ b/docs/implementation/task-c-customer-contact.md @@ -0,0 +1,105 @@ +# Task C: Customer + Contact Production Module + +## 1. Files Added + +- `src/app/api/crm/customers/route.ts` +- `src/app/api/crm/customers/[id]/route.ts` +- `src/app/api/crm/customers/[id]/contacts/route.ts` +- `src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts` +- `src/features/crm/customers/api/types.ts` +- `src/features/crm/customers/api/service.ts` +- `src/features/crm/customers/api/queries.ts` +- `src/features/crm/customers/api/mutations.ts` +- `src/features/crm/customers/schemas/customer.schema.ts` +- `src/features/crm/customers/server/service.ts` +- `src/features/crm/customers/components/customer-status-badge.tsx` +- `src/features/crm/customers/components/customer-form-sheet.tsx` +- `src/features/crm/customers/components/contact-form-sheet.tsx` +- `src/features/crm/customers/components/customer-cell-action.tsx` +- `src/features/crm/customers/components/customer-columns.tsx` +- `src/features/crm/customers/components/customers-table.tsx` +- `src/features/crm/customers/components/customer-listing.tsx` +- `src/features/crm/customers/components/customer-contacts-tab.tsx` +- `src/features/crm/customers/components/customer-detail.tsx` +- `drizzle/0002_plain_anthem.sql` +- `drizzle/meta/0002_snapshot.json` + +## 2. Files Modified + +- `src/app/dashboard/crm/customers/page.tsx` +- `src/app/dashboard/crm/customers/[id]/page.tsx` +- `src/db/schema.ts` +- `src/db/seeds/foundation.seed.ts` +- `src/features/foundation/audit-log/service.ts` +- `src/features/foundation/audit-log/types.ts` +- `src/lib/auth/rbac.ts` +- `src/lib/searchparams.ts` +- `drizzle/meta/_journal.json` + +## 3. Schema Added + +- `crm_customers` + - organization-scoped customer master with unique `(organization_id, code)` + - branch, CRM classification, contact channels, notes, soft-delete, and audit actor columns +- `crm_customer_contacts` + - organization-scoped child contacts under customer + - primary contact flag, soft-delete, and audit actor columns + +## 4. API Routes Added + +- `GET /api/crm/customers` +- `POST /api/crm/customers` +- `GET /api/crm/customers/[id]` +- `PATCH /api/crm/customers/[id]` +- `DELETE /api/crm/customers/[id]` +- `GET /api/crm/customers/[id]/contacts` +- `POST /api/crm/customers/[id]/contacts` +- `PATCH /api/crm/customers/[id]/contacts/[contactId]` +- `DELETE /api/crm/customers/[id]/contacts/[contactId]` + +## 5. UI Routes Completed + +- `/dashboard/crm/customers` + - production list with React Query, nuqs filters, create button, edit/view/delete actions +- `/dashboard/crm/customers/[id]` + - detail page with Template A style structure + - tabs: Overview, Contacts, Activity, Related Documents Placeholder + +## 6. Permissions Used + +- `crm.customer.read` +- `crm.customer.create` +- `crm.customer.update` +- `crm.customer.delete` +- `crm.contact.read` +- `crm.contact.create` +- `crm.contact.update` +- `crm.contact.delete` + +Admin defaults now include the CRM customer/contact permissions. Regular users inherit read permissions only unless additional permissions are granted. + +## 7. Audit Integration + +- customer create/update/delete writes to `tr_audit_logs` with `entityType = crm_customer` +- contact create/update/delete writes to `tr_audit_logs` with `entityType = crm_customer_contact` +- activity tab reads audit log entries for the current customer and resolves actor names from `users` + +## 8. Document Sequence Integration + +- customer create uses `generateNextDocumentCode({ documentType: 'customer' })` +- branch-specific sequence is honored when `branchId` is provided +- foundation seed now also prepares `crm_customer_group` options for form-driven classification + +## 9. Remaining Risks + +- there are no database foreign keys yet between `crm_customers`, `crm_customer_contacts`, and master option rows; organization filtering is enforced in application code +- branch scope currently resolves from seeded `crm_branch` options only; if branch ownership becomes role-sensitive later, `branch-scope` will need stronger membership-aware rules +- contact primary uniqueness is enforced in transaction logic, not via a database partial unique index +- list/detail UI depends on seeded master options being present for customer status/type/group/channel labels + +## 10. Task D Readiness + +- customer and contact production backbone is ready for enquiry linkage +- activity timeline is already in place for future downstream CRM entities +- customer detail route now has a stable home for related document tabs +- foundation permissions, sequence, branch scope, and audit plumbing are reusable for enquiry and quotation modules diff --git a/drizzle/0002_plain_anthem.sql b/drizzle/0002_plain_anthem.sql new file mode 100644 index 0000000..82b8d0d --- /dev/null +++ b/drizzle/0002_plain_anthem.sql @@ -0,0 +1,52 @@ +CREATE TABLE "crm_customer_contacts" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "customer_id" text NOT NULL, + "name" text NOT NULL, + "position" text, + "department" text, + "phone" text, + "mobile" text, + "email" text, + "is_primary" boolean DEFAULT false NOT NULL, + "notes" text, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "crm_customers" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "branch_id" text, + "code" text NOT NULL, + "name" text NOT NULL, + "abbr" text, + "tax_id" text, + "customer_type" text NOT NULL, + "customer_status" text NOT NULL, + "address" text, + "province" text, + "district" text, + "sub_district" text, + "postal_code" text, + "country" text, + "phone" text, + "fax" text, + "email" text, + "website" text, + "lead_channel" text, + "customer_group" text, + "notes" text, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "crm_customers_org_code_idx" ON "crm_customers" USING btree ("organization_id","code"); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..6d36e16 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,978 @@ +{ + "id": "9f3af9b8-d774-40bb-8b24-29b06332b453", + "prevId": "a21ab199-cb43-48b2-b810-7d4afb9baaef", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.crm_customer_contacts": { + "name": "crm_customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customers": { + "name": "crm_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbr": { + "name": "abbr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_status": { + "name": "customer_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub_district": { + "name": "sub_district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fax": { + "name": "fax", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_group": { + "name": "customer_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_customers_org_code_idx": { + "name": "crm_customers_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_number": { + "name": "current_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "padding_length": { + "name": "padding_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_sequences_org_doc_period_branch_idx": { + "name": "document_sequences_org_doc_period_branch_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "business_role": { + "name": "business_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ms_options": { + "name": "ms_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ms_options_org_category_code_idx": { + "name": "ms_options_org_category_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "products_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tr_audit_logs": { + "name": "tr_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_data": { + "name": "before_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_data": { + "name": "after_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9367b94..cadfc07 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1781171200406, "tag": "0001_orange_mandarin", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1781498575433, + "tag": "0002_plain_anthem", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/plans/task-c.md b/plans/task-c.md new file mode 100644 index 0000000..d9307fa --- /dev/null +++ b/plans/task-c.md @@ -0,0 +1,372 @@ +# Task C: Customer + Contact Production Module + +## ALLA OS CRM vNext + +คุณคือ Senior Full-stack Engineer +ให้เริ่ม implement Customer + Contact production module โดยอิง foundation ที่เสร็จแล้วจาก Task A, Task B, Task B.1 + +## ต้องอ่านก่อน + +```txt +docs/implementation/task-a-template-audit.md +docs/implementation/task-b-template-audit.md +docs/implementation/task-b1-foundation-stabilization.md + +Layout.md +AGENTS.md +.agents/skills/kiranism-shadcn-dashboard + +src/db/schema.ts +src/features/foundation/** +src/features/products/** +src/features/users/** +src/app/dashboard/crm/** +``` + +## เป้าหมาย + +สร้าง production module สำหรับ: + +```txt +Customer +Customer Contact +``` + +ยังไม่ทำ: + +```txt +Enquiry +Quotation +Approval +Dashboard KPI +PDF Generator +Report +Notification +``` + +## Core Rules + +```txt +organizationId = tenant boundary +branchId = business sub-scope +``` + +ทุก customer ต้องมี `organizationId` + +`branchId` ใช้เป็น optional business scope เท่านั้น + +ห้ามใช้ mock CRM service + +ห้าม import จาก: + +```txt +src/features/crm-demo/** +src/constants/mock-api* +``` + +## Scope C1: Customer Schema + +เพิ่ม production schema ใน `src/db/schema.ts` + +ตารางแนะนำ: + +```txt +crm_customers +``` + +Fields ขั้นต่ำ: + +```txt +id +organizationId +branchId +code +name +abbr +taxId +customerType +customerStatus +address +province +district +subDistrict +postalCode +country +phone +fax +email +website +leadChannel +customerGroup +notes +isActive +createdAt +updatedAt +deletedAt +createdBy +updatedBy +``` + +Rules: + +* `organizationId` required +* `code` unique ภายใน organization +* customer status/type ใช้ master options +* code ใช้ document sequence `customer -> CUS` +* mutation ต้อง audit log + +## Scope C2: Customer API + +สร้าง route handlers: + +```txt +src/app/api/crm/customers/route.ts +src/app/api/crm/customers/[id]/route.ts +``` + +รองรับ: + +```txt +GET list +POST create +GET detail +PATCH update +DELETE soft delete +``` + +Rules: + +* ใช้ `requireOrganizationAccess` +* ใช้ permission helper +* filter ด้วย organizationId เสมอ +* ห้าม query ข้าม organization +* create ต้อง generate customer code +* create/update/delete ต้อง audit + +Permissions แนะนำ: + +```txt +crm.customer.read +crm.customer.create +crm.customer.update +crm.customer.delete +``` + +## Scope C3: Customer Feature Layer + +สร้าง: + +```txt +src/features/crm/customers/api/types.ts +src/features/crm/customers/api/service.ts +src/features/crm/customers/api/queries.ts +src/features/crm/customers/api/mutations.ts +src/features/crm/customers/schemas/customer.schema.ts +src/features/crm/customers/components/** +``` + +ใช้ pattern จาก: + +```txt +src/features/products/** +src/features/users/** +``` + +## Scope C4: Customer UI + +แทน placeholder routes: + +```txt +/dashboard/crm/customers +/dashboard/crm/customers/[id] +``` + +List page ต้องมี: + +```txt +PageContainer +DataTable +search +status filter +type filter +branch filter +create button +edit action +view action +soft delete action +``` + +Detail page ใช้ Layout.md Template A + +Tabs: + +```txt +Overview +Contacts +Activity +Related Documents Placeholder +``` + +## Scope C5: Customer Form + +ใช้: + +```txt +useAppForm +Zod +Sheet/Dialog pattern +React Query mutation +``` + +Create/Edit fields: + +```txt +name +abbr +taxId +customerType +customerStatus +branchId +address +province +district +subDistrict +postalCode +phone +fax +email +website +leadChannel +customerGroup +notes +``` + +Rules: + +* customerType/status ดึงจาก master options +* branch ดึงจาก branch scope/master options +* ห้าม hardcode option ใน form + +## Scope C6: Contact Schema + +เพิ่มตาราง: + +```txt +crm_customer_contacts +``` + +Fields ขั้นต่ำ: + +```txt +id +organizationId +customerId +name +position +department +phone +mobile +email +isPrimary +notes +isActive +createdAt +updatedAt +deletedAt +createdBy +updatedBy +``` + +Rules: + +* contact ต้องอยู่ใน organization เดียวกับ customer +* customerId required +* primary contact มีได้ 1 คนต่อ customer ถ้าทำได้ใน MVP +* mutation ต้อง audit + +## Scope C7: Contact API + UI + +Route handlers: + +```txt +src/app/api/crm/customers/[id]/contacts/route.ts +src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts +``` + +รองรับ: + +```txt +GET contacts by customer +POST create contact +PATCH update contact +DELETE soft delete contact +``` + +UI: + +* แสดงใน Customer Detail tab: Contacts +* Create/Edit contact ด้วย Sheet +* Primary badge +* Action edit/delete + +Permissions แนะนำ: + +```txt +crm.contact.read +crm.contact.create +crm.contact.update +crm.contact.delete +``` + +## Scope C8: Activity Timeline + +ใน Customer Detail เพิ่ม Activity tab แบบ production-ready placeholder + +ให้แสดง audit log ของ customer จาก `tr_audit_logs` + +ถ้ายังไม่มี query helper ให้สร้าง minimal service ได้ + +## ห้ามทำใน Task C + +```txt +Enquiry schema +Quotation schema +Approval schema +Quotation item +PDF generation +Dashboard KPI +Report +Notification +``` + +## Output หลังทำเสร็จ + +สรุป: + +1. Files Added +2. Files Modified +3. Schema Added +4. API Routes Added +5. UI Routes Completed +6. Permissions Used +7. Audit Integration +8. Document Sequence Integration +9. Remaining Risks +10. Task D Readiness + +## Definition of Done + +Task C ผ่านเมื่อ: + +* Customer schema พร้อม +* Contact schema พร้อม +* Customer list ใช้งาน production data +* Customer detail ใช้งาน production data +* Customer create/edit/delete ใช้งานได้ +* Contact create/edit/delete ใช้งานได้ +* ใช้ organizationId ทุก query +* ใช้ master options ใน status/type +* ใช้ document sequence ตอนสร้าง customer +* ใช้ audit log ทุก mutation +* ไม่ import mock CRM +* ไม่ทำ Enquiry/Quotation/Approval diff --git a/plans/task-d.md b/plans/task-d.md new file mode 100644 index 0000000..6009810 --- /dev/null +++ b/plans/task-d.md @@ -0,0 +1,409 @@ +# Task D: Enquiry Production Module + +## ALLA OS CRM vNext + +คุณคือ Senior Full-stack Engineer +ให้ implement Enquiry production module โดยต่อยอดจาก Task A, B, B.1, C + +## ต้องอ่านก่อน + +```txt +docs/implementation/task-a-template-audit.md +docs/implementation/task-b-template-audit.md +docs/implementation/task-b1-foundation-stabilization.md + +Layout.md +AGENTS.md +.agents/skills/kiranism-shadcn-dashboard + +src/db/schema.ts +src/features/foundation/** +src/features/crm/customers/** +src/app/dashboard/crm/** +``` + +## เป้าหมาย + +สร้าง production module สำหรับ: + +```txt +Enquiry +Enquiry Follow-up / Activity +Customer + Contact linkage +``` + +ยังไม่ทำ: + +```txt +Quotation production +Approval production +PDF Generator +Report +Dashboard KPI จริง +``` + +## Core Rules + +```txt +organizationId = tenant boundary +branchId = business sub-scope +``` + +ทุก enquiry ต้องมี `organizationId` + +`branchId` ใช้เป็น optional business/document scope + +ห้ามใช้ mock CRM service + +ห้าม import จาก: + +```txt +src/features/crm-demo/** +src/constants/mock-api* +``` + +## Business Flow + +Enquiry คือ Sales Opportunity ก่อนออกใบเสนอราคา + +```txt +Customer + └─ Contact + └─ Enquiry + └─ Quotation ใน Task ถัดไป +``` + +1 Customer มีหลาย Enquiry ได้ +1 Enquiry อ้างอิง Contact ได้ +1 Enquiry ต่อไปจะมีหลาย Quotation ได้ แต่ Task D ยังไม่ต้องสร้าง Quotation + +## Scope D1: Enquiry Schema + +เพิ่ม production schema ใน `src/db/schema.ts` + +ตารางหลัก: + +```txt +crm_enquiries +``` + +Fields ขั้นต่ำ: + +```txt +id +organizationId +branchId +code + +customerId +contactId + +title +description +requirement +projectName +projectLocation + +productType +status +priority +leadChannel + +estimatedValue +chancePercent +expectedCloseDate + +competitor +source +notes + +isHotProject +isActive + +createdAt +updatedAt +deletedAt +createdBy +updatedBy +``` + +Rules: + +* `organizationId` required +* `customerId` required +* `contactId` optional +* `code` unique ภายใน organization +* `status`, `productType`, `priority`, `leadChannel` ใช้ master options +* code ใช้ document sequence `enquiry -> ENQ` +* create/update/delete ต้อง audit log +* ห้ามสร้าง quotation table ใน Task D + +## Scope D2: Enquiry Follow-up Schema + +เพิ่ม table: + +```txt +crm_enquiry_followups +``` + +Fields ขั้นต่ำ: + +```txt +id +organizationId +enquiryId +followupDate +followupType +contactId +outcome +notes +nextFollowupDate +nextAction +createdAt +updatedAt +deletedAt +createdBy +updatedBy +``` + +Rules: + +* follow-up ต้องอยู่ใน organization เดียวกับ enquiry +* mutation ต้อง audit +* ใช้สำหรับ activity timeline ของ enquiry + +## Scope D3: Enquiry API + +สร้าง route handlers: + +```txt +src/app/api/crm/enquiries/route.ts +src/app/api/crm/enquiries/[id]/route.ts +src/app/api/crm/enquiries/[id]/followups/route.ts +src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts +``` + +รองรับ: + +```txt +GET list +POST create +GET detail +PATCH update +DELETE soft delete + +GET followups +POST followup +PATCH followup +DELETE followup +``` + +Rules: + +* ใช้ `requireOrganizationAccess` +* ใช้ permission helper +* filter ด้วย organizationId เสมอ +* customer/contact ต้องอยู่ใน organization เดียวกัน +* create enquiry ต้อง generate code +* ทุก mutation ต้อง audit + +Permissions แนะนำ: + +```txt +crm.enquiry.read +crm.enquiry.create +crm.enquiry.update +crm.enquiry.delete +crm.enquiry.followup.read +crm.enquiry.followup.create +crm.enquiry.followup.update +crm.enquiry.followup.delete +``` + +## Scope D4: Enquiry Feature Layer + +สร้าง: + +```txt +src/features/crm/enquiries/api/types.ts +src/features/crm/enquiries/api/service.ts +src/features/crm/enquiries/api/queries.ts +src/features/crm/enquiries/api/mutations.ts +src/features/crm/enquiries/schemas/enquiry.schema.ts +src/features/crm/enquiries/server/service.ts +src/features/crm/enquiries/components/** +``` + +ใช้ pattern จาก: + +```txt +src/features/products/** +src/features/users/** +src/features/crm/customers/** +``` + +## Scope D5: Enquiry UI + +แทน placeholder routes: + +```txt +/dashboard/crm/enquiries +/dashboard/crm/enquiries/[id] +``` + +List page ต้องมี: + +```txt +PageContainer +DataTable +search +status filter +product type filter +priority filter +branch filter +customer filter +create button +edit action +view action +soft delete action +``` + +Detail page ใช้ Layout.md Template A + +Tabs: + +```txt +Overview +Follow-ups +Activity +Related Quotations Placeholder +``` + +## Scope D6: Enquiry Form + +ใช้: + +```txt +useAppForm +Zod +Sheet/Dialog pattern +React Query mutation +``` + +Create/Edit fields: + +```txt +customerId +contactId +title +description +requirement +projectName +projectLocation +branchId +productType +status +priority +leadChannel +estimatedValue +chancePercent +expectedCloseDate +competitor +source +isHotProject +notes +``` + +Rules: + +* customer dropdown ดึงจาก production customer API +* contact dropdown filter ตาม customer ที่เลือก +* status/productType/priority/leadChannel ดึงจาก master options +* branch ดึงจาก branch scope/master options +* ห้าม hardcode option ใน form + +## Scope D7: Follow-up UI + +ใน Enquiry Detail tab: Follow-ups + +ต้องมี: + +```txt +followup list +create followup +edit followup +delete followup +next followup date +next action +outcome +``` + +ใช้ Sheet/Dialog pattern + +## Scope D8: Activity Timeline + +ใน Enquiry Detail เพิ่ม Activity tab + +อ่าน audit log จาก `tr_audit_logs` + +ใช้: + +```txt +entityType = crm_enquiry +entityId = enquiry.id +``` + +และ follow-up audit ถ้าเหมาะสม + +## Scope D9: Customer Detail Integration + +ใน Customer Detail page เพิ่ม section/tab หรือ related placeholder ให้เห็น Enquiry ที่เกี่ยวข้องกับ customer + +ยังไม่ต้องทำ dashboard KPI + +## ห้ามทำใน Task D + +```txt +Quotation schema +Quotation API +Approval schema +Approval workflow +PDF generation +Report +Dashboard KPI จริง +Notification +``` + +## Output หลังทำเสร็จ + +สรุป: + +1. Files Added +2. Files Modified +3. Schema Added +4. API Routes Added +5. UI Routes Completed +6. Permissions Used +7. Audit Integration +8. Document Sequence Integration +9. Customer/Contact Integration +10. Remaining Risks +11. Task E Readiness + +## Definition of Done + +Task D ผ่านเมื่อ: + +* Enquiry schema พร้อม +* Enquiry follow-up schema พร้อม +* Enquiry list ใช้ production data +* Enquiry detail ใช้ production data +* Enquiry create/edit/delete ใช้งานได้ +* Follow-up create/edit/delete ใช้งานได้ +* ใช้ organizationId ทุก query +* customer/contact ต้องอยู่ organization เดียวกัน +* ใช้ master options ใน status/productType/priority/leadChannel +* ใช้ document sequence ตอนสร้าง enquiry +* ใช้ audit log ทุก mutation +* ไม่ import mock CRM +* ไม่ทำ Quotation/Approval diff --git a/src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts b/src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts new file mode 100644 index 0000000..434ecd9 --- /dev/null +++ b/src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + listCustomerContacts, + softDeleteCustomerContact, + updateCustomerContact +} from '@/features/crm/customers/server/service'; +import { customerContactSchema } from '@/features/crm/customers/schemas/customer.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string; contactId: string }>; +}; + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id, contactId } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmContactUpdate + }); + const payload = customerContactSchema.parse(await request.json()); + const before = (await listCustomerContacts(id, organization.id)).find( + (contact) => contact.id === contactId + ); + + if (!before) { + return NextResponse.json({ message: 'Contact not found' }, { status: 404 }); + } + + const updated = await updateCustomerContact( + id, + contactId, + organization.id, + session.user.id, + payload + ); + + await auditUpdate({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_customer_contact', + entityId: contactId, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Contact updated successfully', + contact: updated + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to update contact' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id, contactId } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmContactDelete + }); + const before = (await listCustomerContacts(id, organization.id)).find( + (contact) => contact.id === contactId + ); + + if (!before) { + return NextResponse.json({ message: 'Contact not found' }, { status: 404 }); + } + + const updated = await softDeleteCustomerContact( + id, + contactId, + organization.id, + session.user.id + ); + + await auditDelete({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_customer_contact', + entityId: contactId, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Contact deleted successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to delete contact' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/customers/[id]/contacts/route.ts b/src/app/api/crm/customers/[id]/contacts/route.ts new file mode 100644 index 0000000..1c69f1b --- /dev/null +++ b/src/app/api/crm/customers/[id]/contacts/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate } from '@/features/foundation/audit-log/service'; +import { + createCustomerContact, + listCustomerContacts +} from '@/features/crm/customers/server/service'; +import { customerContactSchema } from '@/features/crm/customers/schemas/customer.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmContactRead + }); + const items = await listCustomerContacts(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Customer contacts loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load contacts' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmContactCreate + }); + const payload = customerContactSchema.parse(await request.json()); + const created = await createCustomerContact(id, organization.id, session.user.id, payload); + + await auditCreate({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_customer_contact', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Contact created successfully', + contact: created + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to create contact' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/customers/[id]/route.ts b/src/app/api/crm/customers/[id]/route.ts new file mode 100644 index 0000000..0d494e4 --- /dev/null +++ b/src/app/api/crm/customers/[id]/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + getCustomerActivity, + getCustomerDetail, + softDeleteCustomer, + updateCustomer +} from '@/features/crm/customers/server/service'; +import { customerSchema } from '@/features/crm/customers/schemas/customer.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const customerRequestSchema = customerSchema.extend({ + branchId: z.string().nullable().optional(), + leadChannel: z.string().nullable().optional(), + customerGroup: z.string().nullable().optional() +}); + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmCustomerRead + }); + const [customer, activity] = await Promise.all([ + getCustomerDetail(id, organization.id), + getCustomerActivity(id, organization.id) + ]); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Customer loaded successfully', + customer, + activity + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load customer' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmCustomerUpdate + }); + const payload = customerRequestSchema.parse(await request.json()); + const before = await getCustomerDetail(id, organization.id); + const updated = await updateCustomer(id, organization.id, session.user.id, payload); + + await auditUpdate({ + organizationId: organization.id, + branchId: updated.branchId, + userId: session.user.id, + entityType: 'crm_customer', + entityId: id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Customer updated successfully', + customer: updated + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to update customer' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmCustomerDelete + }); + const before = await getCustomerDetail(id, organization.id); + const updated = await softDeleteCustomer(id, organization.id, session.user.id); + + await auditDelete({ + organizationId: organization.id, + branchId: updated.branchId, + userId: session.user.id, + entityType: 'crm_customer', + entityId: id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Customer deleted successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to delete customer' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/customers/route.ts b/src/app/api/crm/customers/route.ts new file mode 100644 index 0000000..6525985 --- /dev/null +++ b/src/app/api/crm/customers/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate } from '@/features/foundation/audit-log/service'; +import { createCustomer, listCustomers } from '@/features/crm/customers/server/service'; +import { customerSchema } from '@/features/crm/customers/schemas/customer.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const customerRequestSchema = customerSchema.extend({ + branchId: z.string().nullable().optional(), + leadChannel: z.string().nullable().optional(), + customerGroup: z.string().nullable().optional() +}); + +export async function GET(request: NextRequest) { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmCustomerRead + }); + const { searchParams } = request.nextUrl; + const page = Number(searchParams.get('page') ?? 1); + const limit = Number(searchParams.get('limit') ?? 10); + const search = searchParams.get('search') ?? undefined; + const customerStatus = searchParams.get('customerStatus') ?? undefined; + const customerType = searchParams.get('customerType') ?? undefined; + const branch = searchParams.get('branch') ?? undefined; + const sort = searchParams.get('sort') ?? undefined; + const result = await listCustomers(organization.id, { + page, + limit, + search, + customerStatus, + customerType, + branch, + sort + }); + const offset = (page - 1) * limit; + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Customers loaded successfully', + totalItems: result.totalItems, + offset, + limit, + items: result.items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load customers' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmCustomerCreate + }); + const payload = customerRequestSchema.parse(await request.json()); + const created = await createCustomer(organization.id, session.user.id, payload); + + await auditCreate({ + organizationId: organization.id, + branchId: created.branchId, + userId: session.user.id, + entityType: 'crm_customer', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Customer created successfully', + customer: created + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to create customer' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/crm/customers/[id]/page.tsx b/src/app/dashboard/crm/customers/[id]/page.tsx index 8ab2eb0..0c4cdef 100644 --- a/src/app/dashboard/crm/customers/[id]/page.tsx +++ b/src/app/dashboard/crm/customers/[id]/page.tsx @@ -1,5 +1,11 @@ +import { auth } from '@/auth'; +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; +import { CustomerDetail } from '@/features/crm/customers/components/customer-detail'; +import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries'; +import { getCustomerReferenceData } from '@/features/crm/customers/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; type PageProps = { params: Promise<{ id: string }>; @@ -7,24 +13,69 @@ type PageProps = { export default async function CustomerDetailRoute({ params }: PageProps) { const { id } = await params; + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmCustomerRead))); + const canUpdate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmCustomerUpdate))); + const canContactCreate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmContactCreate))); + const canContactUpdate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmContactUpdate))); + const canContactDelete = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmContactDelete))); + const queryClient = getQueryClient(); + + if (canRead) { + void queryClient.prefetchQuery(customerByIdOptions(id)); + void queryClient.prefetchQuery(customerContactsOptions(id)); + } + + const referenceData = + canRead && session?.user?.activeOrganizationId + ? await getCustomerReferenceData(session.user.activeOrganizationId) + : null; return ( + You do not have access to this customer record. + + } > - + {referenceData ? ( + + + + ) : null} ); } diff --git a/src/app/dashboard/crm/customers/page.tsx b/src/app/dashboard/crm/customers/page.tsx index 894270f..1ebf5c5 100644 --- a/src/app/dashboard/crm/customers/page.tsx +++ b/src/app/dashboard/crm/customers/page.tsx @@ -1,29 +1,74 @@ +import { auth } from '@/auth'; import PageContainer from '@/components/layout/page-container'; -import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; +import CustomerListing from '@/features/crm/customers/components/customer-listing'; +import { CustomerFormSheetTrigger } from '@/features/crm/customers/components/customer-form-sheet'; +import { getCustomerReferenceData } from '@/features/crm/customers/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { searchParamsCache } from '@/lib/searchparams'; +import type { SearchParams } from 'nuqs/server'; export const metadata = { title: 'Dashboard: CRM Customers' }; -export default function CustomersRoute() { +type PageProps = { + searchParams: Promise; +}; + +export default async function CustomersRoute(props: PageProps) { + const searchParams = await props.searchParams; + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmCustomerRead))); + const canCreate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmCustomerCreate))); + const canUpdate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmCustomerUpdate))); + const canDelete = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmCustomerDelete))); + + searchParamsCache.parse(searchParams); + + const referenceData = + canRead && session?.user?.activeOrganizationId + ? await getCustomerReferenceData(session.user.activeOrganizationId) + : null; + return ( + You do not have access to CRM customer records. + + } + pageHeaderAction={ + canCreate && referenceData ? ( + + ) : null + } > - + {referenceData ? ( + + ) : null} ); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 8b89726..70fe911 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -128,3 +128,63 @@ export const trAuditLogs = pgTable('tr_audit_logs', { requestId: text('request_id'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull() }); + +export const crmCustomers = pgTable( + 'crm_customers', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + branchId: text('branch_id'), + code: text('code').notNull(), + name: text('name').notNull(), + abbr: text('abbr'), + taxId: text('tax_id'), + customerType: text('customer_type').notNull(), + customerStatus: text('customer_status').notNull(), + address: text('address'), + province: text('province'), + district: text('district'), + subDistrict: text('sub_district'), + postalCode: text('postal_code'), + country: text('country'), + phone: text('phone'), + fax: text('fax'), + email: text('email'), + website: text('website'), + leadChannel: text('lead_channel'), + customerGroup: text('customer_group'), + notes: text('notes'), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() + }, + (table) => ({ + organizationCodeIdx: uniqueIndex('crm_customers_org_code_idx').on( + table.organizationId, + table.code + ) + }) +); + +export const crmCustomerContacts = pgTable('crm_customer_contacts', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + customerId: text('customer_id').notNull(), + name: text('name').notNull(), + position: text('position'), + department: text('department'), + phone: text('phone'), + mobile: text('mobile'), + email: text('email'), + isPrimary: boolean('is_primary').default(false).notNull(), + notes: text('notes'), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() +}); diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 06a2e86..0a6fc83 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -91,6 +91,11 @@ const FOUNDATION_OPTIONS = { { code: 'company', label: 'Company', value: 'company', sortOrder: 1 }, { code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 } ], + crm_customer_group: [ + { code: 'strategic', label: 'Strategic', value: 'strategic', sortOrder: 1 }, + { code: 'standard', label: 'Standard', value: 'standard', sortOrder: 2 }, + { code: 'project', label: 'Project', value: 'project', sortOrder: 3 } + ], crm_enquiry_status: [ { code: 'new', label: 'New', value: 'new', sortOrder: 1 }, { code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 }, diff --git a/src/features/crm/customers/api/mutations.ts b/src/features/crm/customers/api/mutations.ts new file mode 100644 index 0000000..ba3ca36 --- /dev/null +++ b/src/features/crm/customers/api/mutations.ts @@ -0,0 +1,74 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { + createCustomer, + createCustomerContact, + deleteCustomer, + deleteCustomerContact, + updateCustomer, + updateCustomerContact +} from './service'; +import { customerKeys } from './queries'; +import type { CustomerContactMutationPayload, CustomerMutationPayload } from './types'; + +export const createCustomerMutation = mutationOptions({ + mutationFn: (data: CustomerMutationPayload) => createCustomer(data), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: customerKeys.all }); + } +}); + +export const updateCustomerMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: CustomerMutationPayload }) => + updateCustomer(id, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: customerKeys.all }); + getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.id) }); + } +}); + +export const deleteCustomerMutation = mutationOptions({ + mutationFn: (id: string) => deleteCustomer(id), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: customerKeys.all }); + } +}); + +export const createCustomerContactMutation = mutationOptions({ + mutationFn: ({ + customerId, + values + }: { + customerId: string; + values: CustomerContactMutationPayload; + }) => createCustomerContact(customerId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(variables.customerId) }); + getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.customerId) }); + } +}); + +export const updateCustomerContactMutation = mutationOptions({ + mutationFn: ({ + customerId, + contactId, + values + }: { + customerId: string; + contactId: string; + values: CustomerContactMutationPayload; + }) => updateCustomerContact(customerId, contactId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(variables.customerId) }); + getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.customerId) }); + } +}); + +export const deleteCustomerContactMutation = mutationOptions({ + mutationFn: ({ customerId, contactId }: { customerId: string; contactId: string }) => + deleteCustomerContact(customerId, contactId), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(variables.customerId) }); + getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.customerId) }); + } +}); diff --git a/src/features/crm/customers/api/queries.ts b/src/features/crm/customers/api/queries.ts new file mode 100644 index 0000000..3fe9b2d --- /dev/null +++ b/src/features/crm/customers/api/queries.ts @@ -0,0 +1,28 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getCustomerById, getCustomerContacts, getCustomers } from './service'; +import type { CustomerFilters } from './types'; + +export const customerKeys = { + all: ['crm-customers'] as const, + list: (filters: CustomerFilters) => [...customerKeys.all, 'list', filters] as const, + detail: (id: string) => [...customerKeys.all, 'detail', id] as const, + contacts: (id: string) => [...customerKeys.all, 'contacts', id] as const +}; + +export const customersQueryOptions = (filters: CustomerFilters) => + queryOptions({ + queryKey: customerKeys.list(filters), + queryFn: () => getCustomers(filters) + }); + +export const customerByIdOptions = (id: string) => + queryOptions({ + queryKey: customerKeys.detail(id), + queryFn: () => getCustomerById(id) + }); + +export const customerContactsOptions = (id: string) => + queryOptions({ + queryKey: customerKeys.contacts(id), + queryFn: () => getCustomerContacts(id) + }); diff --git a/src/features/crm/customers/api/service.ts b/src/features/crm/customers/api/service.ts new file mode 100644 index 0000000..116dd13 --- /dev/null +++ b/src/features/crm/customers/api/service.ts @@ -0,0 +1,81 @@ +import { apiClient } from '@/lib/api-client'; +import type { + CustomerContactMutationPayload, + CustomerContactsResponse, + CustomerDetailResponse, + CustomerFilters, + CustomerListResponse, + CustomerMutationPayload, + MutationSuccessResponse +} from './types'; + +export async function getCustomers(filters: CustomerFilters): Promise { + const searchParams = new URLSearchParams(); + + if (filters.page) searchParams.set('page', String(filters.page)); + if (filters.limit) searchParams.set('limit', String(filters.limit)); + if (filters.search) searchParams.set('search', filters.search); + if (filters.customerStatus) searchParams.set('customerStatus', filters.customerStatus); + if (filters.customerType) searchParams.set('customerType', filters.customerType); + if (filters.branch) searchParams.set('branch', filters.branch); + if (filters.sort) searchParams.set('sort', filters.sort); + + const query = searchParams.toString(); + + return apiClient(`/crm/customers${query ? `?${query}` : ''}`); +} + +export async function getCustomerById(id: string): Promise { + return apiClient(`/crm/customers/${id}`); +} + +export async function createCustomer(data: CustomerMutationPayload) { + return apiClient<{ success: boolean; message: string }>('/crm/customers', { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateCustomer(id: string, data: CustomerMutationPayload) { + return apiClient<{ success: boolean; message: string }>(`/crm/customers/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteCustomer(id: string) { + return apiClient(`/crm/customers/${id}`, { + method: 'DELETE' + }); +} + +export async function getCustomerContacts(id: string): Promise { + return apiClient(`/crm/customers/${id}/contacts`); +} + +export async function createCustomerContact( + customerId: string, + data: CustomerContactMutationPayload +) { + return apiClient(`/crm/customers/${customerId}/contacts`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateCustomerContact( + customerId: string, + contactId: string, + data: CustomerContactMutationPayload +) { + return apiClient(`/crm/customers/${customerId}/contacts/${contactId}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteCustomerContact(customerId: string, contactId: string) { + return apiClient(`/crm/customers/${customerId}/contacts/${contactId}`, { + method: 'DELETE' + }); +} diff --git a/src/features/crm/customers/api/types.ts b/src/features/crm/customers/api/types.ts new file mode 100644 index 0000000..b21c6b3 --- /dev/null +++ b/src/features/crm/customers/api/types.ts @@ -0,0 +1,161 @@ +export interface CustomerOption { + id: string; + code: string; + label: string; + value: string | null; +} + +export interface BranchOption { + id: string; + code: string; + name: string; + value: string | null; +} + +export interface CustomerRecord { + id: string; + organizationId: string; + branchId: string | null; + code: string; + name: string; + abbr: string | null; + taxId: string | null; + customerType: string; + customerStatus: string; + address: string | null; + province: string | null; + district: string | null; + subDistrict: string | null; + postalCode: string | null; + country: string | null; + phone: string | null; + fax: string | null; + email: string | null; + website: string | null; + leadChannel: string | null; + customerGroup: string | null; + notes: string | null; + isActive: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + createdBy: string; + updatedBy: string; +} + +export interface CustomerListItem extends CustomerRecord { + contactCount: number; +} + +export interface CustomerContactRecord { + id: string; + organizationId: string; + customerId: string; + name: string; + position: string | null; + department: string | null; + phone: string | null; + mobile: string | null; + email: string | null; + isPrimary: boolean; + notes: string | null; + isActive: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + createdBy: string; + updatedBy: string; +} + +export interface CustomerActivityRecord { + id: string; + action: string; + entityType: string; + entityId: string; + createdAt: string; + userId: string; + actorName: string | null; +} + +export interface CustomerReferenceData { + branches: BranchOption[]; + customerTypes: CustomerOption[]; + customerStatuses: CustomerOption[]; + leadChannels: CustomerOption[]; + customerGroups: CustomerOption[]; +} + +export interface CustomerFilters { + page?: number; + limit?: number; + search?: string; + customerStatus?: string; + customerType?: string; + branch?: string; + sort?: string; +} + +export interface CustomerListResponse { + success: boolean; + time: string; + message: string; + totalItems: number; + offset: number; + limit: number; + items: CustomerListItem[]; +} + +export interface CustomerDetailResponse { + success: boolean; + time: string; + message: string; + customer: CustomerRecord; + activity: CustomerActivityRecord[]; +} + +export interface CustomerContactsResponse { + success: boolean; + time: string; + message: string; + items: CustomerContactRecord[]; +} + +export interface CustomerMutationPayload { + name: string; + abbr?: string; + taxId?: string; + customerType: string; + customerStatus: string; + branchId?: string | null; + address?: string; + province?: string; + district?: string; + subDistrict?: string; + postalCode?: string; + country?: string; + phone?: string; + fax?: string; + email?: string; + website?: string; + leadChannel?: string | null; + customerGroup?: string | null; + notes?: string; + isActive?: boolean; +} + +export interface CustomerContactMutationPayload { + name: string; + position?: string; + department?: string; + phone?: string; + mobile?: string; + email?: string; + isPrimary?: boolean; + notes?: string; + isActive?: boolean; +} + +export interface MutationSuccessResponse { + success: boolean; + message: string; +} diff --git a/src/features/crm/customers/components/contact-form-sheet.tsx b/src/features/crm/customers/components/contact-form-sheet.tsx new file mode 100644 index 0000000..9174bee --- /dev/null +++ b/src/features/crm/customers/components/contact-form-sheet.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet'; +import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; +import { createCustomerContactMutation, updateCustomerContactMutation } from '../api/mutations'; +import type { CustomerContactMutationPayload, CustomerContactRecord } from '../api/types'; +import { customerContactSchema, type CustomerContactFormValues } from '../schemas/customer.schema'; + +function toDefaultValues(contact?: CustomerContactRecord): CustomerContactFormValues { + return { + name: contact?.name ?? '', + position: contact?.position ?? '', + department: contact?.department ?? '', + phone: contact?.phone ?? '', + mobile: contact?.mobile ?? '', + email: contact?.email ?? '', + isPrimary: contact?.isPrimary ?? false, + notes: contact?.notes ?? '', + isActive: contact?.isActive ?? true + }; +} + +export function ContactFormSheet({ + customerId, + contact, + open, + onOpenChange +}: { + customerId: string; + contact?: CustomerContactRecord; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const isEdit = !!contact; + const defaultValues = useMemo(() => toDefaultValues(contact), [contact]); + const { FormTextField, FormTextareaField, FormSwitchField } = + useFormFields(); + + const createMutation = useMutation({ + ...createCustomerContactMutation, + onSuccess: () => { + toast.success('Contact created successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to create contact') + }); + + const updateMutation = useMutation({ + ...updateCustomerContactMutation, + onSuccess: () => { + toast.success('Contact updated successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to update contact') + }); + + const form = useAppForm({ + defaultValues, + validators: { + onSubmit: customerContactSchema + }, + onSubmit: async ({ value }) => { + const payload: CustomerContactMutationPayload = value; + + if (isEdit && contact) { + await updateMutation.mutateAsync({ + customerId, + contactId: contact.id, + values: payload + }); + return; + } + + await createMutation.mutateAsync({ + customerId, + values: payload + }); + } + }); + + useEffect(() => { + if (!open) { + return; + } + + form.reset(defaultValues); + }, [defaultValues, form, open]); + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( + + + + {isEdit ? 'Edit Contact' : 'New Contact'} + + Capture the operational contact person for this customer account. + + + +
+ + + + + + + + +
+ +
+
+ + +
+
+
+
+ + + + + +
+
+ ); +} diff --git a/src/features/crm/customers/components/customer-cell-action.tsx b/src/features/crm/customers/components/customer-cell-action.tsx new file mode 100644 index 0000000..7de8ab3 --- /dev/null +++ b/src/features/crm/customers/components/customer-cell-action.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { AlertModal } from '@/components/modal/alert-modal'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { deleteCustomerMutation } from '../api/mutations'; +import type { CustomerRecord, CustomerReferenceData } from '../api/types'; +import { CustomerFormSheet } from './customer-form-sheet'; + +export function CustomerCellAction({ + data, + referenceData, + canUpdate, + canDelete +}: { + data: CustomerRecord; + referenceData: CustomerReferenceData; + canUpdate: boolean; + canDelete: boolean; +}) { + const router = useRouter(); + const [deleteOpen, setDeleteOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + + const deleteMutation = useMutation({ + ...deleteCustomerMutation, + onSuccess: () => { + toast.success('Customer deleted successfully'); + setDeleteOpen(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to delete customer') + }); + + return ( + <> + setDeleteOpen(false)} + onConfirm={() => deleteMutation.mutate(data.id)} + loading={deleteMutation.isPending} + /> + + + + + + + Actions + router.push(`/dashboard/crm/customers/${data.id}`)}> + View + + setEditOpen(true)} disabled={!canUpdate}> + Edit + + setDeleteOpen(true)} disabled={!canDelete}> + Delete + + + + + ); +} diff --git a/src/features/crm/customers/components/customer-columns.tsx b/src/features/crm/customers/components/customer-columns.tsx new file mode 100644 index 0000000..9f22c31 --- /dev/null +++ b/src/features/crm/customers/components/customer-columns.tsx @@ -0,0 +1,138 @@ +'use client'; + +import Link from 'next/link'; +import type { Column, ColumnDef } from '@tanstack/react-table'; +import { Badge } from '@/components/ui/badge'; +import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; +import { Icons } from '@/components/icons'; +import type { CustomerListItem, CustomerReferenceData } from '../api/types'; +import { CustomerCellAction } from './customer-cell-action'; +import { CustomerStatusBadge } from './customer-status-badge'; + +export function getCustomerColumns({ + referenceData, + canUpdate, + canDelete +}: { + referenceData: CustomerReferenceData; + canUpdate: boolean; + canDelete: boolean; +}): ColumnDef[] { + const branchMap = new Map(referenceData.branches.map((item) => [item.id, item])); + const typeMap = new Map(referenceData.customerTypes.map((item) => [item.id, item])); + const statusMap = new Map(referenceData.customerStatuses.map((item) => [item.id, item])); + + return [ + { + id: 'name', + accessorKey: 'name', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.original.name} + + + {row.original.code} + {row.original.email ? ` • ${row.original.email}` : ''} + +
+ ), + meta: { + label: 'Customer', + placeholder: 'Search customers...', + variant: 'text' as const, + icon: Icons.search + }, + enableColumnFilter: true + }, + { + id: 'customerStatus', + accessorKey: 'customerStatus', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => { + const option = statusMap.get(row.original.customerStatus); + return ; + }, + meta: { + label: 'Status', + variant: 'multiSelect' as const, + options: referenceData.customerStatuses.map((item) => ({ + value: item.id, + label: item.label + })) + }, + enableColumnFilter: true + }, + { + id: 'customerType', + accessorKey: 'customerType', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {typeMap.get(row.original.customerType)?.label ?? 'Unknown'} + + ), + meta: { + label: 'Type', + variant: 'multiSelect' as const, + options: referenceData.customerTypes.map((item) => ({ + value: item.id, + label: item.label + })) + }, + enableColumnFilter: true + }, + { + id: 'branch', + accessorFn: (row) => row.branchId ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.branchId + ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') + : 'Unassigned'} + + ), + meta: { + label: 'Branch', + variant: 'multiSelect' as const, + options: referenceData.branches.map((item) => ({ + value: item.id, + label: item.name + })) + }, + enableColumnFilter: true + }, + { + id: 'contactCount', + accessorKey: 'contactCount', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.contactCount} + }, + { + id: 'actions', + cell: ({ row }) => ( + + ) + } + ]; +} diff --git a/src/features/crm/customers/components/customer-contacts-tab.tsx b/src/features/crm/customers/components/customer-contacts-tab.tsx new file mode 100644 index 0000000..4fe71f9 --- /dev/null +++ b/src/features/crm/customers/components/customer-contacts-tab.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { AlertModal } from '@/components/modal/alert-modal'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { deleteCustomerContactMutation } from '../api/mutations'; +import { customerContactsOptions } from '../api/queries'; +import { ContactFormSheet } from './contact-form-sheet'; + +export function CustomerContactsTab({ + customerId, + canCreate, + canUpdate, + canDelete +}: { + customerId: string; + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; +}) { + const { data } = useSuspenseQuery(customerContactsOptions(customerId)); + const [selectedId, setSelectedId] = useState(null); + const [open, setOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const selectedContact = data.items.find((contact) => contact.id === selectedId); + + const deleteMutation = useMutation({ + ...deleteCustomerContactMutation, + onSuccess: () => { + toast.success('Contact deleted successfully'); + setDeleteOpen(false); + setSelectedId(null); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to delete contact') + }); + + return ( + + +
+ Contacts + Manage the people attached to this customer account. +
+ {canCreate ? ( + + ) : null} +
+ + {!data.items.length ? ( +
+ No contacts have been added yet. +
+ ) : ( + data.items.map((contact) => ( +
+
+
+ {contact.name} + {contact.isPrimary ? Primary : null} + {!contact.isActive ? Inactive : null} +
+
+ {[contact.position, contact.department].filter(Boolean).join(' • ') || + 'No role details'} +
+
+ {[contact.phone, contact.mobile, contact.email].filter(Boolean).join(' • ') || + 'No contact methods'} +
+ {contact.notes ?

{contact.notes}

: null} +
+
+ + +
+
+ )) + )} +
+ + + setDeleteOpen(false)} + onConfirm={() => { + if (selectedId) { + deleteMutation.mutate({ customerId, contactId: selectedId }); + } + }} + loading={deleteMutation.isPending} + /> +
+ ); +} diff --git a/src/features/crm/customers/components/customer-detail.tsx b/src/features/crm/customers/components/customer-detail.tsx new file mode 100644 index 0000000..f0ae6d2 --- /dev/null +++ b/src/features/crm/customers/components/customer-detail.tsx @@ -0,0 +1,255 @@ +'use client'; + +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { customerByIdOptions } from '../api/queries'; +import type { CustomerReferenceData } from '../api/types'; +import { CustomerFormSheet } from './customer-form-sheet'; +import { CustomerContactsTab } from './customer-contacts-tab'; +import { CustomerStatusBadge } from './customer-status-badge'; + +function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { + return ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +export function CustomerDetail({ + customerId, + referenceData, + canUpdate, + canManageContacts +}: { + customerId: string; + referenceData: CustomerReferenceData; + canUpdate: boolean; + canManageContacts: { + create: boolean; + update: boolean; + delete: boolean; + }; +}) { + const { data } = useSuspenseQuery(customerByIdOptions(customerId)); + const [editOpen, setEditOpen] = useState(false); + const branchMap = useMemo( + () => new Map(referenceData.branches.map((item) => [item.id, item.name])), + [referenceData] + ); + const typeMap = useMemo( + () => new Map(referenceData.customerTypes.map((item) => [item.id, item])), + [referenceData] + ); + const statusMap = useMemo( + () => new Map(referenceData.customerStatuses.map((item) => [item.id, item])), + [referenceData] + ); + const leadMap = useMemo( + () => new Map(referenceData.leadChannels.map((item) => [item.id, item.label])), + [referenceData] + ); + const groupMap = useMemo( + () => new Map(referenceData.customerGroups.map((item) => [item.id, item.label])), + [referenceData] + ); + const customer = data.customer; + const status = statusMap.get(customer.customerStatus); + const type = typeMap.get(customer.customerType); + + return ( +
+
+
+
+ + {customer.code} + + {type ? {type.label} : null} + {customer.branchId ? ( + + {branchMap.get(customer.branchId) ?? 'Unknown branch'} + + ) : null} +
+
+

{customer.name}

+

+ {customer.abbr || customer.email || customer.phone || 'Customer detail record'} +

+
+
+
+ {canUpdate ? ( + + ) : null} +
+
+ +
+
+ + + + + Overview + Contacts + Activity + Related Documents + + + + + Overview + + Core account data, location, and CRM classification. + + + + + + + + + + + +
+ +
+
+ +
+
+
+
+ + + + + + + Activity + + Audit log entries captured from customer mutations. + + + + {!data.activity.length ? ( +
+ No activity recorded yet. +
+ ) : ( + data.activity.map((item, index) => ( +
+
+
+
+
+ + {item.action} + + + {item.actorName ?? item.userId} + +
+
+ {new Date(item.createdAt).toLocaleString()} +
+
+
+ {index < data.activity.length - 1 ? : null} +
+ )) + )} + + + + + + + Related Documents + + Task C stops at customer and contact scope, so downstream CRM documents stay + intentionally deferred. + + + +
+ Enquiry, quotation, and approval links will land here in Task D and later. +
+
+
+
+ + + +
+ +
+ + + Record Snapshot + Operational metadata for this customer row. + + + + + + + + +
+
+ + +
+ ); +} diff --git a/src/features/crm/customers/components/customer-form-sheet.tsx b/src/features/crm/customers/components/customer-form-sheet.tsx new file mode 100644 index 0000000..41f8b6e --- /dev/null +++ b/src/features/crm/customers/components/customer-form-sheet.tsx @@ -0,0 +1,250 @@ +'use client'; + +import * as React from 'react'; +import { useEffect, useMemo } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet'; +import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; +import { createCustomerMutation, updateCustomerMutation } from '../api/mutations'; +import type { CustomerMutationPayload, CustomerRecord, CustomerReferenceData } from '../api/types'; +import { customerSchema, type CustomerFormValues } from '../schemas/customer.schema'; + +interface CustomerFormSheetProps { + customer?: CustomerRecord; + open: boolean; + onOpenChange: (open: boolean) => void; + referenceData: CustomerReferenceData; +} + +function toDefaultValues( + customer: CustomerRecord | undefined, + referenceData: CustomerReferenceData +): CustomerFormValues { + return { + name: customer?.name ?? '', + abbr: customer?.abbr ?? '', + taxId: customer?.taxId ?? '', + customerType: customer?.customerType ?? referenceData.customerTypes[0]?.id ?? '', + customerStatus: customer?.customerStatus ?? referenceData.customerStatuses[0]?.id ?? '', + branchId: customer?.branchId ?? undefined, + address: customer?.address ?? '', + province: customer?.province ?? '', + district: customer?.district ?? '', + subDistrict: customer?.subDistrict ?? '', + postalCode: customer?.postalCode ?? '', + country: customer?.country ?? 'Thailand', + phone: customer?.phone ?? '', + fax: customer?.fax ?? '', + email: customer?.email ?? '', + website: customer?.website ?? '', + leadChannel: customer?.leadChannel ?? undefined, + customerGroup: customer?.customerGroup ?? undefined, + notes: customer?.notes ?? '', + isActive: customer?.isActive ?? true + }; +} + +export function CustomerFormSheet({ + customer, + open, + onOpenChange, + referenceData +}: CustomerFormSheetProps) { + const isEdit = !!customer; + const defaultValues = useMemo( + () => toDefaultValues(customer, referenceData), + [customer, referenceData] + ); + const { FormTextField, FormSelectField, FormTextareaField, FormSwitchField } = + useFormFields(); + + const createMutation = useMutation({ + ...createCustomerMutation, + onSuccess: () => { + toast.success('Customer created successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to create customer') + }); + + const updateMutation = useMutation({ + ...updateCustomerMutation, + onSuccess: () => { + toast.success('Customer updated successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to update customer') + }); + + const form = useAppForm({ + defaultValues, + validators: { + onSubmit: customerSchema + }, + onSubmit: async ({ value }) => { + const payload: CustomerMutationPayload = { + ...value, + branchId: value.branchId || null, + leadChannel: value.leadChannel || null, + customerGroup: value.customerGroup || null + }; + + if (isEdit && customer) { + await updateMutation.mutateAsync({ id: customer.id, values: payload }); + return; + } + + await createMutation.mutateAsync(payload); + } + }); + + useEffect(() => { + if (!open) { + return; + } + + form.reset(defaultValues); + }, [defaultValues, form, open]); + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( + + + + {isEdit ? 'Edit Customer' : 'New Customer'} + + Maintain the core account profile, ownership branch, and CRM classification here. + + + +
+ + + + + + + + + + + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: branch.id, + label: branch.name + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> +
+ +
+ + + + +
+ +
+
+ +
+
+
+
+ + + + + +
+
+ ); +} + +export function CustomerFormSheetTrigger({ + referenceData +}: { + referenceData: CustomerReferenceData; +}) { + const [open, setOpen] = React.useState(false); + + return ( + <> + + + + ); +} diff --git a/src/features/crm/customers/components/customer-listing.tsx b/src/features/crm/customers/components/customer-listing.tsx new file mode 100644 index 0000000..93104b7 --- /dev/null +++ b/src/features/crm/customers/components/customer-listing.tsx @@ -0,0 +1,42 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { searchParamsCache } from '@/lib/searchparams'; +import type { CustomerReferenceData } from '../api/types'; +import { customersQueryOptions } from '../api/queries'; +import { CustomersTable } from './customers-table'; + +export default function CustomerListing({ + referenceData, + canUpdate, + canDelete +}: { + referenceData: CustomerReferenceData; + canUpdate: boolean; + canDelete: boolean; +}) { + const page = searchParamsCache.get('page'); + const limit = searchParamsCache.get('perPage'); + const search = searchParamsCache.get('name'); + const customerStatus = searchParamsCache.get('customerStatus'); + const customerType = searchParamsCache.get('customerType'); + const branch = searchParamsCache.get('branch'); + const sort = searchParamsCache.get('sort'); + const filters = { + page, + limit, + ...(search && { search }), + ...(customerStatus && { customerStatus }), + ...(customerType && { customerType }), + ...(branch && { branch }), + ...(sort && { sort }) + }; + const queryClient = getQueryClient(); + + void queryClient.prefetchQuery(customersQueryOptions(filters)); + + return ( + + + + ); +} diff --git a/src/features/crm/customers/components/customer-status-badge.tsx b/src/features/crm/customers/components/customer-status-badge.tsx new file mode 100644 index 0000000..2bd29f4 --- /dev/null +++ b/src/features/crm/customers/components/customer-status-badge.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { Icons } from '@/components/icons'; + +export function CustomerStatusBadge({ code, label }: { code?: string | null; label: string }) { + const normalized = code?.toLowerCase() ?? ''; + const variant = + normalized === 'active' + ? 'default' + : normalized === 'inactive' || normalized === 'suspended' + ? 'outline' + : 'secondary'; + const Icon = + normalized === 'active' + ? Icons.circleCheck + : normalized === 'inactive' || normalized === 'suspended' + ? Icons.warning + : Icons.circle; + + return ( + + + {label} + + ); +} diff --git a/src/features/crm/customers/components/customers-table.tsx b/src/features/crm/customers/components/customers-table.tsx new file mode 100644 index 0000000..6fccb51 --- /dev/null +++ b/src/features/crm/customers/components/customers-table.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useMemo } from 'react'; +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { DataTable } from '@/components/ui/table/data-table'; +import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar'; +import { useDataTable } from '@/hooks/use-data-table'; +import { getSortingStateParser } from '@/lib/parsers'; +import { customersQueryOptions } from '../api/queries'; +import type { CustomerReferenceData } from '../api/types'; +import { getCustomerColumns } from './customer-columns'; + +export function CustomersTable({ + referenceData, + canUpdate, + canDelete +}: { + referenceData: CustomerReferenceData; + canUpdate: boolean; + canDelete: boolean; +}) { + const columns = useMemo( + () => getCustomerColumns({ referenceData, canUpdate, canDelete }), + [referenceData, canUpdate, canDelete] + ); + const columnIds = columns.map((column) => column.id).filter(Boolean) as string[]; + const [params] = useQueryStates({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + name: parseAsString, + customerStatus: parseAsString, + customerType: parseAsString, + branch: parseAsString, + sort: getSortingStateParser(columnIds).withDefault([]) + }); + + const filters = { + page: params.page, + limit: params.perPage, + ...(params.name && { search: params.name }), + ...(params.customerStatus && { customerStatus: params.customerStatus }), + ...(params.customerType && { customerType: params.customerType }), + ...(params.branch && { branch: params.branch }), + ...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }) + }; + + const { data } = useSuspenseQuery(customersQueryOptions(filters)); + const pageCount = Math.ceil(data.totalItems / params.perPage); + const { table } = useDataTable({ + data: data.items, + columns, + pageCount, + shallow: true, + debounceMs: 500, + initialState: { + columnPinning: { right: ['actions'] } + } + }); + + return ( + + + + ); +} diff --git a/src/features/crm/customers/schemas/customer.schema.ts b/src/features/crm/customers/schemas/customer.schema.ts new file mode 100644 index 0000000..fd9524b --- /dev/null +++ b/src/features/crm/customers/schemas/customer.schema.ts @@ -0,0 +1,45 @@ +import * as z from 'zod'; + +export const customerSchema = z.object({ + name: z.string().min(2, 'Customer name must be at least 2 characters'), + abbr: z.string().optional(), + taxId: z.string().optional(), + customerType: z.string().min(1, 'Please select a customer type'), + customerStatus: z.string().min(1, 'Please select a customer status'), + branchId: z.string().optional().nullable(), + address: z.string().optional(), + province: z.string().optional(), + district: z.string().optional(), + subDistrict: z.string().optional(), + postalCode: z.string().optional(), + country: z.string().optional(), + phone: z.string().optional(), + fax: z.string().optional(), + email: z + .string() + .optional() + .refine((value) => !value || z.email().safeParse(value).success, 'Please enter a valid email'), + website: z.string().optional(), + leadChannel: z.string().optional().nullable(), + customerGroup: z.string().optional().nullable(), + notes: z.string().optional(), + isActive: z.boolean().default(true) +}); + +export const customerContactSchema = z.object({ + name: z.string().min(2, 'Contact name must be at least 2 characters'), + position: z.string().optional(), + department: z.string().optional(), + phone: z.string().optional(), + mobile: z.string().optional(), + email: z + .string() + .optional() + .refine((value) => !value || z.email().safeParse(value).success, 'Please enter a valid email'), + isPrimary: z.boolean().default(false), + notes: z.string().optional(), + isActive: z.boolean().default(true) +}); + +export type CustomerFormValues = z.input; +export type CustomerContactFormValues = z.input; diff --git a/src/features/crm/customers/server/service.ts b/src/features/crm/customers/server/service.ts new file mode 100644 index 0000000..155e2e4 --- /dev/null +++ b/src/features/crm/customers/server/service.ts @@ -0,0 +1,646 @@ +import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm'; +import { crmCustomerContacts, crmCustomers, msOptions, users } from '@/db/schema'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import { listAuditLogs } from '@/features/foundation/audit-log/service'; +import { validateBranchAccess, getUserBranches } from '@/features/foundation/branch-scope/service'; +import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; +import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import type { + BranchOption, + CustomerActivityRecord, + CustomerContactMutationPayload, + CustomerContactRecord, + CustomerFilters, + CustomerListItem, + CustomerMutationPayload, + CustomerOption, + CustomerRecord, + CustomerReferenceData +} from '../api/types'; + +const CUSTOMER_OPTION_CATEGORIES = { + customerType: 'crm_customer_type', + customerStatus: 'crm_customer_status', + leadChannel: 'crm_lead_channel', + customerGroup: 'crm_customer_group' +} as const; + +function mapCustomerOption( + option: Awaited>[number] +): CustomerOption { + return { + id: option.id, + code: option.code, + label: option.label, + value: option.value + }; +} + +function mapBranchOption( + branch: Awaited>[number] +): BranchOption { + return { + id: branch.id, + code: branch.code, + name: branch.name, + value: branch.value + }; +} + +function mapCustomerRecord(row: typeof crmCustomers.$inferSelect): CustomerRecord { + return { + id: row.id, + organizationId: row.organizationId, + branchId: row.branchId, + code: row.code, + name: row.name, + abbr: row.abbr, + taxId: row.taxId, + customerType: row.customerType, + customerStatus: row.customerStatus, + address: row.address, + province: row.province, + district: row.district, + subDistrict: row.subDistrict, + postalCode: row.postalCode, + country: row.country, + phone: row.phone, + fax: row.fax, + email: row.email, + website: row.website, + leadChannel: row.leadChannel, + customerGroup: row.customerGroup, + notes: row.notes, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + createdBy: row.createdBy, + updatedBy: row.updatedBy + }; +} + +function mapCustomerContactRecord( + row: typeof crmCustomerContacts.$inferSelect +): CustomerContactRecord { + return { + id: row.id, + organizationId: row.organizationId, + customerId: row.customerId, + name: row.name, + position: row.position, + department: row.department, + phone: row.phone, + mobile: row.mobile, + email: row.email, + isPrimary: row.isPrimary, + notes: row.notes, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + createdBy: row.createdBy, + updatedBy: row.updatedBy + }; +} + +function parseSort(sort?: string) { + if (!sort) { + return desc(crmCustomers.updatedAt); + } + + try { + const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>; + const [rule] = parsed; + + if (!rule) { + return desc(crmCustomers.updatedAt); + } + + const sortMap = { + code: crmCustomers.code, + name: crmCustomers.name, + customerStatus: crmCustomers.customerStatus, + customerType: crmCustomers.customerType, + branch: crmCustomers.branchId, + updatedAt: crmCustomers.updatedAt + } as const; + + const column = sortMap[rule.id as keyof typeof sortMap]; + + if (!column) { + return desc(crmCustomers.updatedAt); + } + + return rule.desc ? desc(column) : asc(column); + } catch { + return desc(crmCustomers.updatedAt); + } +} + +function splitFilterValue(value?: string) { + return value + ? value + .split(/[.,]/) + .map((item) => item.trim()) + .filter(Boolean) + : []; +} + +async function resolveValidOptionIds(category: string, organizationId: string) { + const options = await getActiveOptionsByCategory(category, { organizationId }); + return new Set(options.map((option) => option.id)); +} + +async function assertMasterOptionValue( + organizationId: string, + category: string, + optionId?: string | null +) { + if (!optionId) { + return; + } + + const validIds = await resolveValidOptionIds(category, organizationId); + + if (!validIds.has(optionId)) { + throw new AuthError(`Invalid option for ${category}`, 400); + } +} + +async function assertCustomerBelongsToOrganization(id: string, organizationId: string) { + const customer = await db.query.crmCustomers.findFirst({ + where: and( + eq(crmCustomers.id, id), + eq(crmCustomers.organizationId, organizationId), + isNull(crmCustomers.deletedAt) + ) + }); + + if (!customer) { + throw new AuthError('Customer not found', 404); + } + + return customer; +} + +async function assertContactBelongsToCustomer( + contactId: string, + customerId: string, + organizationId: string +) { + const contact = await db.query.crmCustomerContacts.findFirst({ + where: and( + eq(crmCustomerContacts.id, contactId), + eq(crmCustomerContacts.customerId, customerId), + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt) + ) + }); + + if (!contact) { + throw new AuthError('Contact not found', 404); + } + + return contact; +} + +async function validateCustomerPayload(organizationId: string, payload: CustomerMutationPayload) { + await assertMasterOptionValue( + organizationId, + CUSTOMER_OPTION_CATEGORIES.customerType, + payload.customerType + ); + await assertMasterOptionValue( + organizationId, + CUSTOMER_OPTION_CATEGORIES.customerStatus, + payload.customerStatus + ); + await assertMasterOptionValue( + organizationId, + CUSTOMER_OPTION_CATEGORIES.leadChannel, + payload.leadChannel ?? null + ); + await assertMasterOptionValue( + organizationId, + CUSTOMER_OPTION_CATEGORIES.customerGroup, + payload.customerGroup ?? null + ); + + if (payload.branchId) { + await validateBranchAccess(payload.branchId); + } +} + +function buildCustomerFilters(organizationId: string, filters: CustomerFilters): SQL[] { + const statuses = splitFilterValue(filters.customerStatus); + const customerTypes = splitFilterValue(filters.customerType); + const branches = splitFilterValue(filters.branch); + + return [ + eq(crmCustomers.organizationId, organizationId), + isNull(crmCustomers.deletedAt), + ...(statuses.length ? [inArray(crmCustomers.customerStatus, statuses)] : []), + ...(customerTypes.length ? [inArray(crmCustomers.customerType, customerTypes)] : []), + ...(branches.length ? [inArray(crmCustomers.branchId, branches)] : []), + ...(filters.search + ? [ + or( + ilike(crmCustomers.code, `%${filters.search}%`), + ilike(crmCustomers.name, `%${filters.search}%`), + ilike(crmCustomers.abbr, `%${filters.search}%`), + ilike(crmCustomers.taxId, `%${filters.search}%`), + ilike(crmCustomers.email, `%${filters.search}%`) + )! + ] + : []) + ]; +} + +export async function getCustomerReferenceData( + organizationId: string +): Promise { + const [branches, customerTypes, customerStatuses, leadChannels, customerGroups] = + await Promise.all([ + getUserBranches(), + getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerType, { organizationId }), + getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerStatus, { organizationId }), + getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.leadChannel, { organizationId }), + getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerGroup, { organizationId }) + ]); + + return { + branches: branches.map(mapBranchOption), + customerTypes: customerTypes.map(mapCustomerOption), + customerStatuses: customerStatuses.map(mapCustomerOption), + leadChannels: leadChannels.map(mapCustomerOption), + customerGroups: customerGroups.map(mapCustomerOption) + }; +} + +export async function listCustomers( + organizationId: string, + filters: CustomerFilters +): Promise<{ items: CustomerListItem[]; totalItems: number }> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 10; + const whereFilters = buildCustomerFilters(organizationId, filters); + const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); + const offset = (page - 1) * limit; + + const [totalResult] = await db.select({ value: count() }).from(crmCustomers).where(where); + const rows = await db + .select() + .from(crmCustomers) + .where(where) + .orderBy(parseSort(filters.sort)) + .limit(limit) + .offset(offset); + + const customerIds = rows.map((row) => row.id); + const contactCounts = customerIds.length + ? await db + .select({ + customerId: crmCustomerContacts.customerId, + value: count() + }) + .from(crmCustomerContacts) + .where( + and( + eq(crmCustomerContacts.organizationId, organizationId), + inArray(crmCustomerContacts.customerId, customerIds), + isNull(crmCustomerContacts.deletedAt) + ) + ) + .groupBy(crmCustomerContacts.customerId) + : []; + const countMap = new Map(contactCounts.map((item) => [item.customerId, item.value])); + + return { + items: rows.map((row) => ({ + ...mapCustomerRecord(row), + contactCount: countMap.get(row.id) ?? 0 + })), + totalItems: totalResult?.value ?? 0 + }; +} + +export async function getCustomerDetail( + id: string, + organizationId: string +): Promise { + const customer = await assertCustomerBelongsToOrganization(id, organizationId); + return mapCustomerRecord(customer); +} + +export async function getCustomerActivity( + id: string, + organizationId: string +): Promise { + const auditLogs = await listAuditLogs({ + organizationId, + entityType: 'crm_customer', + entityId: id, + limit: 25 + }); + const userIds = [...new Set(auditLogs.map((log) => log.userId))]; + const actorRows = userIds.length + ? await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(inArray(users.id, userIds)) + : []; + const actorMap = new Map(actorRows.map((row) => [row.id, row.name])); + + return auditLogs.map((log) => ({ + id: log.id, + action: log.action, + entityType: log.entityType, + entityId: log.entityId, + createdAt: log.createdAt, + userId: log.userId, + actorName: actorMap.get(log.userId) ?? null + })); +} + +export async function createCustomer( + organizationId: string, + userId: string, + payload: CustomerMutationPayload +) { + await validateCustomerPayload(organizationId, payload); + + const documentCode = await generateNextDocumentCode({ + organizationId, + documentType: 'customer', + branchId: payload.branchId ?? '' + }); + + const [created] = await db + .insert(crmCustomers) + .values({ + id: crypto.randomUUID(), + organizationId, + branchId: payload.branchId ?? null, + code: documentCode.code, + name: payload.name.trim(), + abbr: payload.abbr?.trim() || null, + taxId: payload.taxId?.trim() || null, + customerType: payload.customerType, + customerStatus: payload.customerStatus, + address: payload.address?.trim() || null, + province: payload.province?.trim() || null, + district: payload.district?.trim() || null, + subDistrict: payload.subDistrict?.trim() || null, + postalCode: payload.postalCode?.trim() || null, + country: payload.country?.trim() || null, + phone: payload.phone?.trim() || null, + fax: payload.fax?.trim() || null, + email: payload.email?.trim() || null, + website: payload.website?.trim() || null, + leadChannel: payload.leadChannel ?? null, + customerGroup: payload.customerGroup ?? null, + notes: payload.notes?.trim() || null, + isActive: payload.isActive ?? true, + createdBy: userId, + updatedBy: userId + }) + .returning(); + + return created; +} + +export async function updateCustomer( + id: string, + organizationId: string, + userId: string, + payload: CustomerMutationPayload +) { + await validateCustomerPayload(organizationId, payload); + await assertCustomerBelongsToOrganization(id, organizationId); + + const [updated] = await db + .update(crmCustomers) + .set({ + branchId: payload.branchId ?? null, + name: payload.name.trim(), + abbr: payload.abbr?.trim() || null, + taxId: payload.taxId?.trim() || null, + customerType: payload.customerType, + customerStatus: payload.customerStatus, + address: payload.address?.trim() || null, + province: payload.province?.trim() || null, + district: payload.district?.trim() || null, + subDistrict: payload.subDistrict?.trim() || null, + postalCode: payload.postalCode?.trim() || null, + country: payload.country?.trim() || null, + phone: payload.phone?.trim() || null, + fax: payload.fax?.trim() || null, + email: payload.email?.trim() || null, + website: payload.website?.trim() || null, + leadChannel: payload.leadChannel ?? null, + customerGroup: payload.customerGroup ?? null, + notes: payload.notes?.trim() || null, + isActive: payload.isActive ?? true, + updatedBy: userId, + updatedAt: new Date() + }) + .where(eq(crmCustomers.id, id)) + .returning(); + + return updated; +} + +export async function softDeleteCustomer(id: string, organizationId: string, userId: string) { + await assertCustomerBelongsToOrganization(id, organizationId); + + const now = new Date(); + + const [updated] = await db + .update(crmCustomers) + .set({ + deletedAt: now, + updatedAt: now, + updatedBy: userId, + isActive: false + }) + .where(eq(crmCustomers.id, id)) + .returning(); + + await db + .update(crmCustomerContacts) + .set({ + deletedAt: now, + updatedAt: now, + updatedBy: userId, + isActive: false + }) + .where( + and( + eq(crmCustomerContacts.customerId, id), + eq(crmCustomerContacts.organizationId, organizationId) + ) + ); + + return updated; +} + +export async function listCustomerContacts(id: string, organizationId: string) { + await assertCustomerBelongsToOrganization(id, organizationId); + const rows = await db.query.crmCustomerContacts.findMany({ + where: and( + eq(crmCustomerContacts.customerId, id), + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt) + ), + orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)] + }); + + return rows.map(mapCustomerContactRecord); +} + +export async function createCustomerContact( + customerId: string, + organizationId: string, + userId: string, + payload: CustomerContactMutationPayload +) { + await assertCustomerBelongsToOrganization(customerId, organizationId); + + return db.transaction(async (tx) => { + if (payload.isPrimary) { + await tx + .update(crmCustomerContacts) + .set({ + isPrimary: false, + updatedAt: new Date(), + updatedBy: userId + }) + .where( + and( + eq(crmCustomerContacts.customerId, customerId), + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt) + ) + ); + } + + const [created] = await tx + .insert(crmCustomerContacts) + .values({ + id: crypto.randomUUID(), + organizationId, + customerId, + name: payload.name.trim(), + position: payload.position?.trim() || null, + department: payload.department?.trim() || null, + phone: payload.phone?.trim() || null, + mobile: payload.mobile?.trim() || null, + email: payload.email?.trim() || null, + isPrimary: payload.isPrimary ?? false, + notes: payload.notes?.trim() || null, + isActive: payload.isActive ?? true, + createdBy: userId, + updatedBy: userId + }) + .returning(); + + return created; + }); +} + +export async function updateCustomerContact( + customerId: string, + contactId: string, + organizationId: string, + userId: string, + payload: CustomerContactMutationPayload +) { + await assertCustomerBelongsToOrganization(customerId, organizationId); + await assertContactBelongsToCustomer(contactId, customerId, organizationId); + + return db.transaction(async (tx) => { + if (payload.isPrimary) { + await tx + .update(crmCustomerContacts) + .set({ + isPrimary: false, + updatedAt: new Date(), + updatedBy: userId + }) + .where( + and( + eq(crmCustomerContacts.customerId, customerId), + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt) + ) + ); + } + + const [updated] = await tx + .update(crmCustomerContacts) + .set({ + name: payload.name.trim(), + position: payload.position?.trim() || null, + department: payload.department?.trim() || null, + phone: payload.phone?.trim() || null, + mobile: payload.mobile?.trim() || null, + email: payload.email?.trim() || null, + isPrimary: payload.isPrimary ?? false, + notes: payload.notes?.trim() || null, + isActive: payload.isActive ?? true, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmCustomerContacts.id, contactId)) + .returning(); + + return updated; + }); +} + +export async function softDeleteCustomerContact( + customerId: string, + contactId: string, + organizationId: string, + userId: string +) { + await assertCustomerBelongsToOrganization(customerId, organizationId); + await assertContactBelongsToCustomer(contactId, customerId, organizationId); + + const [updated] = await db + .update(crmCustomerContacts) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + updatedBy: userId, + isActive: false, + isPrimary: false + }) + .where(eq(crmCustomerContacts.id, contactId)) + .returning(); + + return updated; +} + +export async function resolveCustomerOptionLabel( + organizationId: string, + category: string, + id: string | null +) { + if (!id) { + return null; + } + + const row = await db.query.msOptions.findFirst({ + where: and( + eq(msOptions.organizationId, organizationId), + eq(msOptions.category, category), + eq(msOptions.id, id), + isNull(msOptions.deletedAt) + ) + }); + + return row?.label ?? null; +} diff --git a/src/features/foundation/audit-log/service.ts b/src/features/foundation/audit-log/service.ts index d5ade7b..a7bce32 100644 --- a/src/features/foundation/audit-log/service.ts +++ b/src/features/foundation/audit-log/service.ts @@ -1,8 +1,9 @@ +import { and, desc, eq, type SQL } from 'drizzle-orm'; import { headers } from 'next/headers'; import { trAuditLogs } from '@/db/schema'; import { db } from '@/lib/db'; import { getCurrentUserContext } from '@/features/foundation/auth-context/service'; -import type { AuditLogInput, AuditLogRecord } from './types'; +import type { AuditLogInput, AuditLogListFilters, AuditLogRecord } from './types'; async function resolveAuditContext(input: AuditLogInput) { const context = await getCurrentUserContext(); @@ -96,3 +97,21 @@ export async function auditDelete(input: Omit) { action: 'delete' }); } + +export async function listAuditLogs(filters: AuditLogListFilters): Promise { + const whereFilters: SQL[] = [ + ...(filters.organizationId ? [eq(trAuditLogs.organizationId, filters.organizationId)] : []), + ...(filters.entityType ? [eq(trAuditLogs.entityType, filters.entityType)] : []), + ...(filters.entityId ? [eq(trAuditLogs.entityId, filters.entityId)] : []) + ]; + const where = whereFilters.length > 1 ? and(...whereFilters) : whereFilters[0]; + const limit = filters.limit ?? 20; + + const rows = await db.query.trAuditLogs.findMany({ + where, + orderBy: [desc(trAuditLogs.createdAt)], + limit + }); + + return rows.map(mapAuditRecord); +} diff --git a/src/features/foundation/audit-log/types.ts b/src/features/foundation/audit-log/types.ts index 4f0ac7b..2cc8a07 100644 --- a/src/features/foundation/audit-log/types.ts +++ b/src/features/foundation/audit-log/types.ts @@ -23,3 +23,10 @@ export interface AuditLogRecord { requestId: string | null; createdAt: string; } + +export interface AuditLogListFilters { + organizationId?: string; + entityType?: string; + entityId?: string; + limit?: number; +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index c34e72c..6f05b4a 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -18,7 +18,15 @@ export const PERMISSIONS = { productsWrite: 'products:write', organizationManage: 'organization:manage', usersManage: 'users:manage', - reportRead: 'report:read' + reportRead: 'report:read', + crmCustomerRead: 'crm.customer.read', + crmCustomerCreate: 'crm.customer.create', + crmCustomerUpdate: 'crm.customer.update', + crmCustomerDelete: 'crm.customer.delete', + crmContactRead: 'crm.contact.read', + crmContactCreate: 'crm.contact.create', + crmContactUpdate: 'crm.contact.update', + crmContactDelete: 'crm.contact.delete' } as const; export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; @@ -29,11 +37,19 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { PERMISSIONS.productsRead, PERMISSIONS.productsWrite, PERMISSIONS.organizationManage, - PERMISSIONS.usersManage + PERMISSIONS.usersManage, + PERMISSIONS.crmCustomerRead, + PERMISSIONS.crmCustomerCreate, + PERMISSIONS.crmCustomerUpdate, + PERMISSIONS.crmCustomerDelete, + PERMISSIONS.crmContactRead, + PERMISSIONS.crmContactCreate, + PERMISSIONS.crmContactUpdate, + PERMISSIONS.crmContactDelete ]; } - return [PERMISSIONS.productsRead]; + return [PERMISSIONS.productsRead, PERMISSIONS.crmCustomerRead, PERMISSIONS.crmContactRead]; } export function getBusinessRolePermissions(role: BusinessRole): Permission[] { diff --git a/src/lib/searchparams.ts b/src/lib/searchparams.ts index 6037048..1dae4c3 100644 --- a/src/lib/searchparams.ts +++ b/src/lib/searchparams.ts @@ -11,6 +11,7 @@ export const searchParams = { name: parseAsString, gender: parseAsString, category: parseAsString, + customerType: parseAsString, customerStatus: parseAsString, branch: parseAsString, productType: parseAsString,