compleate
This commit is contained in:
phaichayon
2026-06-15 13:20:39 +07:00
parent b8cd39eaa4
commit 8148850fda
33 changed files with 4800 additions and 35 deletions

View 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

View 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");

View 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": {}
}
}

View File

@@ -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
View 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
View 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

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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()
});

View File

@@ -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 },

View 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) });
}
});

View 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)
});

View 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'
});
}

View 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;
}

View 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>
);
}

View File

@@ -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>
</>
);
}

View 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}
/>
)
}
];
}

View 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>
);
}

View 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>
);
}

View 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} />
</>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>;

View 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;
}

View File

@@ -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);
}

View File

@@ -23,3 +23,10 @@ export interface AuditLogRecord {
requestId: string | null;
createdAt: string;
}
export interface AuditLogListFilters {
organizationId?: string;
entityType?: string;
entityId?: string;
limit?: number;
}

View File

@@ -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[] {

View File

@@ -11,6 +11,7 @@ export const searchParams = {
name: parseAsString,
gender: parseAsString,
category: parseAsString,
customerType: parseAsString,
customerStatus: parseAsString,
branch: parseAsString,
productType: parseAsString,