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,
|
||||
"tag": "0001_orange_mandarin",
|
||||
"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 { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||
import { CustomerDetail } from '@/features/crm/customers/components/customer-detail';
|
||||
import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries';
|
||||
import { getCustomerReferenceData } from '@/features/crm/customers/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -7,24 +13,69 @@ type PageProps = {
|
||||
|
||||
export default async function CustomerDetailRoute({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
const canRead =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmCustomerRead)));
|
||||
const canUpdate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmCustomerUpdate)));
|
||||
const canContactCreate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmContactCreate)));
|
||||
const canContactUpdate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmContactUpdate)));
|
||||
const canContactDelete =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmContactDelete)));
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (canRead) {
|
||||
void queryClient.prefetchQuery(customerByIdOptions(id));
|
||||
void queryClient.prefetchQuery(customerContactsOptions(id));
|
||||
}
|
||||
|
||||
const referenceData =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await getCustomerReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
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
|
||||
title='Customer detail pending'
|
||||
summary='This detail route is intentionally isolated from legacy demo state until the production Customer module is implemented.'
|
||||
foundationItems={[
|
||||
'Current organization context',
|
||||
'Permission checks',
|
||||
'Branch validation abstraction',
|
||||
'Audit log helper'
|
||||
]}
|
||||
nextStep='Implement customer detail using real customer, contact, enquiry, and audit data from production APIs.'
|
||||
demoHref={`/dashboard/crm-demo/customers/${id}`}
|
||||
{referenceData ? (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<CustomerDetail
|
||||
customerId={id}
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canManageContacts={{
|
||||
create: canContactCreate,
|
||||
update: canContactUpdate,
|
||||
delete: canContactDelete
|
||||
}}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,74 @@
|
||||
import { auth } from '@/auth';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||
import CustomerListing from '@/features/crm/customers/components/customer-listing';
|
||||
import { CustomerFormSheetTrigger } from '@/features/crm/customers/components/customer-form-sheet';
|
||||
import { getCustomerReferenceData } from '@/features/crm/customers/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import type { SearchParams } from 'nuqs/server';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard: CRM Customers'
|
||||
};
|
||||
|
||||
export default function CustomersRoute() {
|
||||
type PageProps = {
|
||||
searchParams: Promise<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 (
|
||||
<PageContainer
|
||||
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
|
||||
title='Customer module pending'
|
||||
summary='The old customer list and form flow has been moved to the demo path so this route no longer depends on mock services.'
|
||||
foundationItems={[
|
||||
'Organization-first access boundary',
|
||||
'Customer status options from master options',
|
||||
'Branch scope abstraction for customer ownership',
|
||||
'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'
|
||||
{referenceData ? (
|
||||
<CustomerListing
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,3 +128,63 @@ export const trAuditLogs = pgTable('tr_audit_logs', {
|
||||
requestId: text('request_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
|
||||
});
|
||||
|
||||
export const crmCustomers = pgTable(
|
||||
'crm_customers',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
branchId: text('branch_id'),
|
||||
code: text('code').notNull(),
|
||||
name: text('name').notNull(),
|
||||
abbr: text('abbr'),
|
||||
taxId: text('tax_id'),
|
||||
customerType: text('customer_type').notNull(),
|
||||
customerStatus: text('customer_status').notNull(),
|
||||
address: text('address'),
|
||||
province: text('province'),
|
||||
district: text('district'),
|
||||
subDistrict: text('sub_district'),
|
||||
postalCode: text('postal_code'),
|
||||
country: text('country'),
|
||||
phone: text('phone'),
|
||||
fax: text('fax'),
|
||||
email: text('email'),
|
||||
website: text('website'),
|
||||
leadChannel: text('lead_channel'),
|
||||
customerGroup: text('customer_group'),
|
||||
notes: text('notes'),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdBy: text('created_by').notNull(),
|
||||
updatedBy: text('updated_by').notNull()
|
||||
},
|
||||
(table) => ({
|
||||
organizationCodeIdx: uniqueIndex('crm_customers_org_code_idx').on(
|
||||
table.organizationId,
|
||||
table.code
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
export const crmCustomerContacts = pgTable('crm_customer_contacts', {
|
||||
id: text('id').primaryKey(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
customerId: text('customer_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
position: text('position'),
|
||||
department: text('department'),
|
||||
phone: text('phone'),
|
||||
mobile: text('mobile'),
|
||||
email: text('email'),
|
||||
isPrimary: boolean('is_primary').default(false).notNull(),
|
||||
notes: text('notes'),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdBy: text('created_by').notNull(),
|
||||
updatedBy: text('updated_by').notNull()
|
||||
});
|
||||
|
||||
@@ -91,6 +91,11 @@ const FOUNDATION_OPTIONS = {
|
||||
{ code: 'company', label: 'Company', value: 'company', sortOrder: 1 },
|
||||
{ code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 }
|
||||
],
|
||||
crm_customer_group: [
|
||||
{ code: 'strategic', label: 'Strategic', value: 'strategic', sortOrder: 1 },
|
||||
{ code: 'standard', label: 'Standard', value: 'standard', sortOrder: 2 },
|
||||
{ code: 'project', label: 'Project', value: 'project', sortOrder: 3 }
|
||||
],
|
||||
crm_enquiry_status: [
|
||||
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
|
||||
{ code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 },
|
||||
|
||||
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 { trAuditLogs } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { getCurrentUserContext } from '@/features/foundation/auth-context/service';
|
||||
import type { AuditLogInput, AuditLogRecord } from './types';
|
||||
import type { AuditLogInput, AuditLogListFilters, AuditLogRecord } from './types';
|
||||
|
||||
async function resolveAuditContext(input: AuditLogInput) {
|
||||
const context = await getCurrentUserContext();
|
||||
@@ -96,3 +97,21 @@ export async function auditDelete(input: Omit<AuditLogInput, 'action'>) {
|
||||
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;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditLogListFilters {
|
||||
organizationId?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,15 @@ export const PERMISSIONS = {
|
||||
productsWrite: 'products:write',
|
||||
organizationManage: 'organization:manage',
|
||||
usersManage: 'users:manage',
|
||||
reportRead: 'report:read'
|
||||
reportRead: 'report:read',
|
||||
crmCustomerRead: 'crm.customer.read',
|
||||
crmCustomerCreate: 'crm.customer.create',
|
||||
crmCustomerUpdate: 'crm.customer.update',
|
||||
crmCustomerDelete: 'crm.customer.delete',
|
||||
crmContactRead: 'crm.contact.read',
|
||||
crmContactCreate: 'crm.contact.create',
|
||||
crmContactUpdate: 'crm.contact.update',
|
||||
crmContactDelete: 'crm.contact.delete'
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||
@@ -29,11 +37,19 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
PERMISSIONS.productsRead,
|
||||
PERMISSIONS.productsWrite,
|
||||
PERMISSIONS.organizationManage,
|
||||
PERMISSIONS.usersManage
|
||||
PERMISSIONS.usersManage,
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerCreate,
|
||||
PERMISSIONS.crmCustomerUpdate,
|
||||
PERMISSIONS.crmCustomerDelete,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmContactCreate,
|
||||
PERMISSIONS.crmContactUpdate,
|
||||
PERMISSIONS.crmContactDelete
|
||||
];
|
||||
}
|
||||
|
||||
return [PERMISSIONS.productsRead];
|
||||
return [PERMISSIONS.productsRead, PERMISSIONS.crmCustomerRead, PERMISSIONS.crmContactRead];
|
||||
}
|
||||
|
||||
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const searchParams = {
|
||||
name: parseAsString,
|
||||
gender: parseAsString,
|
||||
category: parseAsString,
|
||||
customerType: parseAsString,
|
||||
customerStatus: parseAsString,
|
||||
branch: parseAsString,
|
||||
productType: parseAsString,
|
||||
|
||||
Reference in New Issue
Block a user