tsk-C
compleate
This commit is contained in:
105
docs/implementation/task-c-customer-contact.md
Normal file
105
docs/implementation/task-c-customer-contact.md
Normal file
@@ -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
|
||||||
52
drizzle/0002_plain_anthem.sql
Normal file
52
drizzle/0002_plain_anthem.sql
Normal file
@@ -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");
|
||||||
978
drizzle/meta/0002_snapshot.json
Normal file
978
drizzle/meta/0002_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1781171200406,
|
"when": 1781171200406,
|
||||||
"tag": "0001_orange_mandarin",
|
"tag": "0001_orange_mandarin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781498575433,
|
||||||
|
"tag": "0002_plain_anthem",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
372
plans/task-c.md
Normal file
372
plans/task-c.md
Normal file
@@ -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
|
||||||
409
plans/task-d.md
Normal file
409
plans/task-d.md
Normal file
@@ -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
|
||||||
119
src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts
Normal file
119
src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/app/api/crm/customers/[id]/contacts/route.ts
Normal file
86
src/app/api/crm/customers/[id]/contacts/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/app/api/crm/customers/[id]/route.ts
Normal file
134
src/app/api/crm/customers/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/app/api/crm/customers/route.ts
Normal file
104
src/app/api/crm/customers/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
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 = {
|
type PageProps = {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -7,24 +13,69 @@ type PageProps = {
|
|||||||
|
|
||||||
export default async function CustomerDetailRoute({ params }: PageProps) {
|
export default async function CustomerDetailRoute({ params }: PageProps) {
|
||||||
const { id } = await params;
|
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 (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Customer Detail'
|
pageTitle='Customer Detail'
|
||||||
pageDescription={`Production detail route placeholder for customer ${id}.`}
|
pageDescription='Customer profile, contacts, audit timeline, and future CRM document handoff.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to this customer record.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CrmProductionPlaceholder
|
{referenceData ? (
|
||||||
title='Customer detail pending'
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
summary='This detail route is intentionally isolated from legacy demo state until the production Customer module is implemented.'
|
<CustomerDetail
|
||||||
foundationItems={[
|
customerId={id}
|
||||||
'Current organization context',
|
referenceData={referenceData}
|
||||||
'Permission checks',
|
canUpdate={canUpdate}
|
||||||
'Branch validation abstraction',
|
canManageContacts={{
|
||||||
'Audit log helper'
|
create: canContactCreate,
|
||||||
]}
|
update: canContactUpdate,
|
||||||
nextStep='Implement customer detail using real customer, contact, enquiry, and audit data from production APIs.'
|
delete: canContactDelete
|
||||||
demoHref={`/dashboard/crm-demo/customers/${id}`}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</HydrationBoundary>
|
||||||
|
) : null}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,74 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
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 = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM Customers'
|
title: 'Dashboard: CRM Customers'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CustomersRoute() {
|
type PageProps = {
|
||||||
|
searchParams: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Customers'
|
pageTitle='Customers'
|
||||||
pageDescription='Production customer CRUD has not started yet. This route is reserved for the Task C implementation path.'
|
pageDescription='Production customer registry with organization scoping, branch filters, contact management, and audit-backed history.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to CRM customer records.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
pageHeaderAction={
|
||||||
|
canCreate && referenceData ? (
|
||||||
|
<CustomerFormSheetTrigger referenceData={referenceData} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CrmProductionPlaceholder
|
{referenceData ? (
|
||||||
title='Customer module pending'
|
<CustomerListing
|
||||||
summary='The old customer list and form flow has been moved to the demo path so this route no longer depends on mock services.'
|
referenceData={referenceData}
|
||||||
foundationItems={[
|
canUpdate={canUpdate}
|
||||||
'Organization-first access boundary',
|
canDelete={canDelete}
|
||||||
'Customer status options from master options',
|
/>
|
||||||
'Branch scope abstraction for customer ownership',
|
) : null}
|
||||||
'Document code generation for customer records',
|
|
||||||
'Audit hooks for future mutations'
|
|
||||||
]}
|
|
||||||
nextStep='Implement Customer list, detail, and create/edit flows with real route handlers and persistence.'
|
|
||||||
demoHref='/dashboard/crm-demo/customers'
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,3 +128,63 @@ export const trAuditLogs = pgTable('tr_audit_logs', {
|
|||||||
requestId: text('request_id'),
|
requestId: text('request_id'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
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()
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ const FOUNDATION_OPTIONS = {
|
|||||||
{ code: 'company', label: 'Company', value: 'company', sortOrder: 1 },
|
{ code: 'company', label: 'Company', value: 'company', sortOrder: 1 },
|
||||||
{ code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 }
|
{ 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: [
|
crm_enquiry_status: [
|
||||||
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
|
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
|
||||||
{ code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 },
|
{ code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 },
|
||||||
|
|||||||
74
src/features/crm/customers/api/mutations.ts
Normal file
74
src/features/crm/customers/api/mutations.ts
Normal file
@@ -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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
28
src/features/crm/customers/api/queries.ts
Normal file
28
src/features/crm/customers/api/queries.ts
Normal file
@@ -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)
|
||||||
|
});
|
||||||
81
src/features/crm/customers/api/service.ts
Normal file
81
src/features/crm/customers/api/service.ts
Normal file
@@ -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<CustomerListResponse> {
|
||||||
|
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<CustomerListResponse>(`/crm/customers${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomerById(id: string): Promise<CustomerDetailResponse> {
|
||||||
|
return apiClient<CustomerDetailResponse>(`/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<MutationSuccessResponse>(`/crm/customers/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomerContacts(id: string): Promise<CustomerContactsResponse> {
|
||||||
|
return apiClient<CustomerContactsResponse>(`/crm/customers/${id}/contacts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCustomerContact(
|
||||||
|
customerId: string,
|
||||||
|
data: CustomerContactMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/customers/${customerId}/contacts`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomerContact(
|
||||||
|
customerId: string,
|
||||||
|
contactId: string,
|
||||||
|
data: CustomerContactMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/customers/${customerId}/contacts/${contactId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomerContact(customerId: string, contactId: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/customers/${customerId}/contacts/${contactId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
161
src/features/crm/customers/api/types.ts
Normal file
161
src/features/crm/customers/api/types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
164
src/features/crm/customers/components/contact-form-sheet.tsx
Normal file
164
src/features/crm/customers/components/contact-form-sheet.tsx
Normal file
@@ -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<CustomerContactFormValues>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className='flex flex-col sm:max-w-2xl'>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{isEdit ? 'Edit Contact' : 'New Contact'}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Capture the operational contact person for this customer account.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className='flex-1 overflow-auto'>
|
||||||
|
<form.AppForm>
|
||||||
|
<form.Form id='customer-contact-form' className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<FormTextField name='name' label='Contact Name' required placeholder='Jane Doe' />
|
||||||
|
<FormTextField name='position' label='Position' placeholder='Procurement Manager' />
|
||||||
|
<FormTextField name='department' label='Department' placeholder='Procurement' />
|
||||||
|
<FormTextField name='phone' label='Phone' placeholder='02-000-0000' />
|
||||||
|
<FormTextField name='mobile' label='Mobile' placeholder='081-000-0000' />
|
||||||
|
<FormTextField
|
||||||
|
name='email'
|
||||||
|
label='Email'
|
||||||
|
type='email'
|
||||||
|
placeholder='jane@example.com'
|
||||||
|
/>
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormTextareaField
|
||||||
|
name='notes'
|
||||||
|
label='Notes'
|
||||||
|
placeholder='Preferred communication style, escalation notes, or meeting context'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
|
||||||
|
<FormSwitchField
|
||||||
|
name='isPrimary'
|
||||||
|
label='Primary Contact'
|
||||||
|
description='When enabled, this contact becomes the main person for this customer.'
|
||||||
|
/>
|
||||||
|
<FormSwitchField
|
||||||
|
name='isActive'
|
||||||
|
label='Active Contact'
|
||||||
|
description='Inactive contacts remain in history and can be restored later.'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form.Form>
|
||||||
|
</form.AppForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' form='customer-contact-form' isLoading={isPending}>
|
||||||
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
|
{isEdit ? 'Update Contact' : 'Create Contact'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<AlertModal
|
||||||
|
isOpen={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate(data.id)}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
<CustomerFormSheet
|
||||||
|
customer={data}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
referenceData={referenceData}
|
||||||
|
/>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||||
|
<span className='sr-only'>Open actions</span>
|
||||||
|
<Icons.ellipsis className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='end'>
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/customers/${data.id}`)}>
|
||||||
|
<Icons.arrowRight className='mr-2 h-4 w-4' /> View
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
|
||||||
|
<Icons.edit className='mr-2 h-4 w-4' /> Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
||||||
|
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
src/features/crm/customers/components/customer-columns.tsx
Normal file
138
src/features/crm/customers/components/customer-columns.tsx
Normal file
@@ -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<CustomerListItem>[] {
|
||||||
|
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<CustomerListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Customer' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/crm/customers/${row.original.id}`}
|
||||||
|
className='font-medium hover:underline'
|
||||||
|
>
|
||||||
|
{row.original.name}
|
||||||
|
</Link>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
{row.original.code}
|
||||||
|
{row.original.email ? ` • ${row.original.email}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
label: 'Customer',
|
||||||
|
placeholder: 'Search customers...',
|
||||||
|
variant: 'text' as const,
|
||||||
|
icon: Icons.search
|
||||||
|
},
|
||||||
|
enableColumnFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customerStatus',
|
||||||
|
accessorKey: 'customerStatus',
|
||||||
|
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Status' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const option = statusMap.get(row.original.customerStatus);
|
||||||
|
return <CustomerStatusBadge code={option?.code} label={option?.label ?? 'Unknown'} />;
|
||||||
|
},
|
||||||
|
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<CustomerListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Type' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant='outline'>
|
||||||
|
{typeMap.get(row.original.customerType)?.label ?? 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
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<CustomerListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Branch' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span>
|
||||||
|
{row.original.branchId
|
||||||
|
? (branchMap.get(row.original.branchId)?.name ?? 'Unknown')
|
||||||
|
: 'Unassigned'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
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<CustomerListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Contacts' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <span>{row.original.contactCount}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<CustomerCellAction
|
||||||
|
data={row.original}
|
||||||
|
referenceData={referenceData}
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
canDelete={canDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
136
src/features/crm/customers/components/customer-contacts-tab.tsx
Normal file
136
src/features/crm/customers/components/customer-contacts-tab.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Contacts</CardTitle>
|
||||||
|
<CardDescription>Manage the people attached to this customer account.</CardDescription>
|
||||||
|
</div>
|
||||||
|
{canCreate ? (
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(null);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.add className='mr-2 h-4 w-4' /> Add Contact
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
{!data.items.length ? (
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
No contacts have been added yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.items.map((contact) => (
|
||||||
|
<div
|
||||||
|
key={contact.id}
|
||||||
|
className='flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-start md:justify-between'
|
||||||
|
>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<span className='font-medium'>{contact.name}</span>
|
||||||
|
{contact.isPrimary ? <Badge>Primary</Badge> : null}
|
||||||
|
{!contact.isActive ? <Badge variant='outline'>Inactive</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
{[contact.position, contact.department].filter(Boolean).join(' • ') ||
|
||||||
|
'No role details'}
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
{[contact.phone, contact.mobile, contact.email].filter(Boolean).join(' • ') ||
|
||||||
|
'No contact methods'}
|
||||||
|
</div>
|
||||||
|
{contact.notes ? <p className='text-sm'>{contact.notes}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={!canUpdate}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(contact.id);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.edit className='mr-2 h-4 w-4' /> Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={!canDelete}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(contact.id);
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<ContactFormSheet
|
||||||
|
customerId={customerId}
|
||||||
|
contact={selectedContact}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
/>
|
||||||
|
<AlertModal
|
||||||
|
isOpen={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (selectedId) {
|
||||||
|
deleteMutation.mutate({ customerId, contactId: selectedId });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
255
src/features/crm/customers/components/customer-detail.tsx
Normal file
255
src/features/crm/customers/components/customer-detail.tsx
Normal file
@@ -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 (
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||||
|
<div className='text-sm'>{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button asChild variant='ghost' size='icon'>
|
||||||
|
<Link href='/dashboard/crm/customers'>
|
||||||
|
<Icons.chevronLeft className='h-4 w-4' />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<span className='text-muted-foreground font-mono text-sm'>{customer.code}</span>
|
||||||
|
<CustomerStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />
|
||||||
|
{type ? <Badge variant='outline'>{type.label}</Badge> : null}
|
||||||
|
{customer.branchId ? (
|
||||||
|
<Badge variant='secondary'>
|
||||||
|
{branchMap.get(customer.branchId) ?? 'Unknown branch'}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-2xl font-semibold'>{customer.name}</h2>
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
{customer.abbr || customer.email || customer.phone || 'Customer detail record'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{canUpdate ? (
|
||||||
|
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||||
|
<Icons.edit className='mr-2 h-4 w-4' /> Edit Customer
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-6 lg:grid-cols-3'>
|
||||||
|
<div className='space-y-6 lg:col-span-2'>
|
||||||
|
<Card>
|
||||||
|
<CardContent className='pt-6'>
|
||||||
|
<Tabs defaultValue='overview' className='gap-4'>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value='contacts'>Contacts</TabsTrigger>
|
||||||
|
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||||
|
<TabsTrigger value='related'>Related Documents</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value='overview'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Overview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Core account data, location, and CRM classification.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
|
<FieldItem label='Tax ID' value={customer.taxId} />
|
||||||
|
<FieldItem label='Phone' value={customer.phone} />
|
||||||
|
<FieldItem label='Email' value={customer.email} />
|
||||||
|
<FieldItem label='Website' value={customer.website} />
|
||||||
|
<FieldItem
|
||||||
|
label='Branch'
|
||||||
|
value={customer.branchId ? branchMap.get(customer.branchId) : null}
|
||||||
|
/>
|
||||||
|
<FieldItem
|
||||||
|
label='Lead Channel'
|
||||||
|
value={leadMap.get(customer.leadChannel ?? '')}
|
||||||
|
/>
|
||||||
|
<FieldItem
|
||||||
|
label='Customer Group'
|
||||||
|
value={groupMap.get(customer.customerGroup ?? '')}
|
||||||
|
/>
|
||||||
|
<FieldItem label='Active' value={customer.isActive ? 'Yes' : 'No'} />
|
||||||
|
<div className='md:col-span-2 xl:col-span-4'>
|
||||||
|
<FieldItem
|
||||||
|
label='Address'
|
||||||
|
value={
|
||||||
|
[
|
||||||
|
customer.address,
|
||||||
|
customer.subDistrict,
|
||||||
|
customer.district,
|
||||||
|
customer.province,
|
||||||
|
customer.postalCode,
|
||||||
|
customer.country
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='md:col-span-2 xl:col-span-4'>
|
||||||
|
<FieldItem label='Notes' value={customer.notes} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value='contacts'>
|
||||||
|
<CustomerContactsTab
|
||||||
|
customerId={customerId}
|
||||||
|
canCreate={canManageContacts.create}
|
||||||
|
canUpdate={canManageContacts.update}
|
||||||
|
canDelete={canManageContacts.delete}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value='activity'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Activity</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Audit log entries captured from customer mutations.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
{!data.activity.length ? (
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
No activity recorded yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.activity.map((item, index) => (
|
||||||
|
<div key={item.id} className='space-y-4'>
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Badge variant='outline' className='capitalize'>
|
||||||
|
{item.action}
|
||||||
|
</Badge>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{item.actorName ?? item.userId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
{new Date(item.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{index < data.activity.length - 1 ? <Separator /> : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value='related'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Related Documents</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Task C stops at customer and contact scope, so downstream CRM documents stay
|
||||||
|
intentionally deferred.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
Enquiry, quotation, and approval links will land here in Task D and later.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Record Snapshot</CardTitle>
|
||||||
|
<CardDescription>Operational metadata for this customer row.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<FieldItem label='Created By' value={customer.createdBy} />
|
||||||
|
<FieldItem label='Updated By' value={customer.updatedBy} />
|
||||||
|
<FieldItem label='Created At' value={new Date(customer.createdAt).toLocaleString()} />
|
||||||
|
<FieldItem label='Updated At' value={new Date(customer.updatedAt).toLocaleString()} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomerFormSheet
|
||||||
|
customer={customer}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
referenceData={referenceData}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
src/features/crm/customers/components/customer-form-sheet.tsx
Normal file
250
src/features/crm/customers/components/customer-form-sheet.tsx
Normal file
@@ -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<CustomerFormValues>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className='flex flex-col sm:max-w-3xl'>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{isEdit ? 'Edit Customer' : 'New Customer'}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Maintain the core account profile, ownership branch, and CRM classification here.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className='flex-1 overflow-auto'>
|
||||||
|
<form.AppForm>
|
||||||
|
<form.Form id='customer-form-sheet' className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<FormTextField
|
||||||
|
name='name'
|
||||||
|
label='Customer Name'
|
||||||
|
required
|
||||||
|
placeholder='ALLA Service Co., Ltd.'
|
||||||
|
/>
|
||||||
|
<FormTextField name='abbr' label='Abbreviation' placeholder='ALLA' />
|
||||||
|
<FormTextField name='taxId' label='Tax ID' placeholder='0105556...' />
|
||||||
|
<FormTextField name='phone' label='Phone' placeholder='02-000-0000' />
|
||||||
|
<FormTextField name='fax' label='Fax' placeholder='02-000-0001' />
|
||||||
|
<FormTextField
|
||||||
|
name='email'
|
||||||
|
label='Email'
|
||||||
|
type='email'
|
||||||
|
placeholder='sales@example.com'
|
||||||
|
/>
|
||||||
|
<FormTextField name='website' label='Website' placeholder='https://example.com' />
|
||||||
|
<FormTextField name='country' label='Country' placeholder='Thailand' />
|
||||||
|
<FormSelectField
|
||||||
|
name='customerType'
|
||||||
|
label='Customer Type'
|
||||||
|
required
|
||||||
|
options={referenceData.customerTypes.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='customerStatus'
|
||||||
|
label='Customer Status'
|
||||||
|
required
|
||||||
|
options={referenceData.customerStatuses.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='branchId'
|
||||||
|
label='Branch'
|
||||||
|
options={referenceData.branches.map((branch) => ({
|
||||||
|
value: branch.id,
|
||||||
|
label: branch.name
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='leadChannel'
|
||||||
|
label='Lead Channel'
|
||||||
|
options={referenceData.leadChannels.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<FormSelectField
|
||||||
|
name='customerGroup'
|
||||||
|
label='Customer Group'
|
||||||
|
options={referenceData.customerGroups.map((item) => ({
|
||||||
|
value: item.id,
|
||||||
|
label: item.label
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormTextField name='address' label='Address' placeholder='123 Main Road' />
|
||||||
|
</div>
|
||||||
|
<FormTextField name='province' label='Province' placeholder='Bangkok' />
|
||||||
|
<FormTextField name='district' label='District' placeholder='Bang Kapi' />
|
||||||
|
<FormTextField name='subDistrict' label='Sub District' placeholder='Hua Mak' />
|
||||||
|
<FormTextField name='postalCode' label='Postal Code' placeholder='10240' />
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormTextareaField
|
||||||
|
name='notes'
|
||||||
|
label='Notes'
|
||||||
|
placeholder='Internal notes about this customer relationship'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormSwitchField
|
||||||
|
name='isActive'
|
||||||
|
label='Active Record'
|
||||||
|
description='Inactive customers remain visible in history but should not be used for new work.'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form.Form>
|
||||||
|
</form.AppForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' form='customer-form-sheet' isLoading={isPending}>
|
||||||
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
|
{isEdit ? 'Update Customer' : 'Create Customer'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerFormSheetTrigger({
|
||||||
|
referenceData
|
||||||
|
}: {
|
||||||
|
referenceData: CustomerReferenceData;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setOpen(true)}>
|
||||||
|
<Icons.add className='mr-2 h-4 w-4' /> Add Customer
|
||||||
|
</Button>
|
||||||
|
<CustomerFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/features/crm/customers/components/customer-listing.tsx
Normal file
42
src/features/crm/customers/components/customer-listing.tsx
Normal file
@@ -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 (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<CustomersTable referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
||||||
|
</HydrationBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Badge variant={variant} className='capitalize'>
|
||||||
|
<Icon className='h-3 w-3' />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/features/crm/customers/components/customers-table.tsx
Normal file
66
src/features/crm/customers/components/customers-table.tsx
Normal file
@@ -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 (
|
||||||
|
<DataTable table={table}>
|
||||||
|
<DataTableToolbar table={table} />
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/features/crm/customers/schemas/customer.schema.ts
Normal file
45
src/features/crm/customers/schemas/customer.schema.ts
Normal file
@@ -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<typeof customerSchema>;
|
||||||
|
export type CustomerContactFormValues = z.input<typeof customerContactSchema>;
|
||||||
646
src/features/crm/customers/server/service.ts
Normal file
646
src/features/crm/customers/server/service.ts
Normal file
@@ -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<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||||
|
): CustomerOption {
|
||||||
|
return {
|
||||||
|
id: option.id,
|
||||||
|
code: option.code,
|
||||||
|
label: option.label,
|
||||||
|
value: option.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBranchOption(
|
||||||
|
branch: Awaited<ReturnType<typeof getUserBranches>>[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<CustomerReferenceData> {
|
||||||
|
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<CustomerRecord> {
|
||||||
|
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
|
||||||
|
return mapCustomerRecord(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomerActivity(
|
||||||
|
id: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<CustomerActivityRecord[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { and, desc, eq, type SQL } from 'drizzle-orm';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { trAuditLogs } from '@/db/schema';
|
import { trAuditLogs } from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getCurrentUserContext } from '@/features/foundation/auth-context/service';
|
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) {
|
async function resolveAuditContext(input: AuditLogInput) {
|
||||||
const context = await getCurrentUserContext();
|
const context = await getCurrentUserContext();
|
||||||
@@ -96,3 +97,21 @@ export async function auditDelete(input: Omit<AuditLogInput, 'action'>) {
|
|||||||
action: 'delete'
|
action: 'delete'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listAuditLogs(filters: AuditLogListFilters): Promise<AuditLogRecord[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,3 +23,10 @@ export interface AuditLogRecord {
|
|||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditLogListFilters {
|
||||||
|
organizationId?: string;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ export const PERMISSIONS = {
|
|||||||
productsWrite: 'products:write',
|
productsWrite: 'products:write',
|
||||||
organizationManage: 'organization:manage',
|
organizationManage: 'organization:manage',
|
||||||
usersManage: 'users: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;
|
} as const;
|
||||||
|
|
||||||
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||||
@@ -29,11 +37,19 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.productsRead,
|
PERMISSIONS.productsRead,
|
||||||
PERMISSIONS.productsWrite,
|
PERMISSIONS.productsWrite,
|
||||||
PERMISSIONS.organizationManage,
|
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[] {
|
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const searchParams = {
|
|||||||
name: parseAsString,
|
name: parseAsString,
|
||||||
gender: parseAsString,
|
gender: parseAsString,
|
||||||
category: parseAsString,
|
category: parseAsString,
|
||||||
|
customerType: parseAsString,
|
||||||
customerStatus: parseAsString,
|
customerStatus: parseAsString,
|
||||||
branch: parseAsString,
|
branch: parseAsString,
|
||||||
productType: parseAsString,
|
productType: parseAsString,
|
||||||
|
|||||||
Reference in New Issue
Block a user