From b8cd39eaa4bf59c23c802b5e42d462cfd0847b21 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 15 Jun 2026 11:19:31 +0700 Subject: [PATCH] task-b task-b.1 complere --- docs/adr/ADR-0006-branch-domain-model.md | 8 + docs/implementation/task-b-template-audit.md | 337 ++++ .../task-b1-foundation-stabilization.md | 279 ++++ package.json | 7 +- plans/task-b.1.md | 468 ++++++ src/app/dashboard/crm-demo/approvals/page.tsx | 26 + .../crm-demo/customers/[id]/page.tsx | 24 + src/app/dashboard/crm-demo/customers/page.tsx | 49 + .../crm-demo/enquiries/[id]/page.tsx | 24 + src/app/dashboard/crm-demo/enquiries/page.tsx | 55 + src/app/dashboard/crm-demo/page.tsx | 25 + .../crm-demo/quotations/[id]/page.tsx | 24 + .../dashboard/crm-demo/quotations/page.tsx | 57 + .../settings/document-sequences/page.tsx | 21 + .../crm-demo/settings/master-options/page.tsx | 37 + .../crm-demo/settings/templates/page.tsx | 21 + src/app/dashboard/crm/approvals/page.tsx | 26 +- src/app/dashboard/crm/customers/[id]/page.tsx | 28 +- src/app/dashboard/crm/customers/page.tsx | 52 +- src/app/dashboard/crm/enquiries/[id]/page.tsx | 28 +- src/app/dashboard/crm/enquiries/page.tsx | 58 +- src/app/dashboard/crm/page.tsx | 30 +- .../dashboard/crm/quotations/[id]/page.tsx | 28 +- src/app/dashboard/crm/quotations/page.tsx | 59 +- .../crm/settings/document-sequences/page.tsx | 24 +- .../dashboard/crm/settings/templates/page.tsx | 20 +- src/app/dashboard/workspaces/page.tsx | 130 +- src/config/nav-config.ts | 164 +- src/db/seeds/foundation.seed.ts | 298 ++++ .../{crm => crm-demo}/api/mutations.ts | 0 src/features/{crm => crm-demo}/api/queries.ts | 7 +- src/features/{crm => crm-demo}/api/service.ts | 191 ++- src/features/{crm => crm-demo}/api/types.ts | 0 .../components/approvals-page.tsx | 6 +- .../components/crm-dashboard.tsx | 63 +- .../components/customer-detail.tsx | 5 +- .../components/customer-form-sheet.tsx | 14 +- .../crm-demo/components/customers-table.tsx | 186 +++ .../crm-demo/components/enquiries-table.tsx | 157 ++ .../components/enquiry-detail.tsx | 0 .../components/quotation-detail.tsx | 4 +- .../crm-demo/components/quotations-table.tsx | 225 +++ .../components/settings-page.tsx | 8 +- .../{crm => crm-demo}/components/shared.tsx | 60 +- src/features/crm-demo/data/mock-crm.ts | 1377 +++++++++++++++++ .../{crm => crm-demo}/data/options.ts | 0 .../{crm => crm-demo}/schemas/customer.ts | 0 .../{crm => crm-demo}/utils/format.ts | 0 .../{crm => crm-demo}/utils/status.ts | 0 .../crm/components/customers-table.tsx | 207 --- .../crm/components/enquiries-table.tsx | 178 --- .../crm/components/quotations-table.tsx | 252 --- src/features/crm/data/mock-crm.ts | 731 --------- .../components/crm-production-placeholder.tsx | 56 + src/lib/searchparams.ts | 9 + 55 files changed, 4295 insertions(+), 1848 deletions(-) create mode 100644 docs/adr/ADR-0006-branch-domain-model.md create mode 100644 docs/implementation/task-b-template-audit.md create mode 100644 docs/implementation/task-b1-foundation-stabilization.md create mode 100644 plans/task-b.1.md create mode 100644 src/app/dashboard/crm-demo/approvals/page.tsx create mode 100644 src/app/dashboard/crm-demo/customers/[id]/page.tsx create mode 100644 src/app/dashboard/crm-demo/customers/page.tsx create mode 100644 src/app/dashboard/crm-demo/enquiries/[id]/page.tsx create mode 100644 src/app/dashboard/crm-demo/enquiries/page.tsx create mode 100644 src/app/dashboard/crm-demo/page.tsx create mode 100644 src/app/dashboard/crm-demo/quotations/[id]/page.tsx create mode 100644 src/app/dashboard/crm-demo/quotations/page.tsx create mode 100644 src/app/dashboard/crm-demo/settings/document-sequences/page.tsx create mode 100644 src/app/dashboard/crm-demo/settings/master-options/page.tsx create mode 100644 src/app/dashboard/crm-demo/settings/templates/page.tsx create mode 100644 src/db/seeds/foundation.seed.ts rename src/features/{crm => crm-demo}/api/mutations.ts (100%) rename src/features/{crm => crm-demo}/api/queries.ts (95%) rename src/features/{crm => crm-demo}/api/service.ts (75%) rename src/features/{crm => crm-demo}/api/types.ts (100%) rename src/features/{crm => crm-demo}/components/approvals-page.tsx (97%) rename src/features/{crm => crm-demo}/components/crm-dashboard.tsx (80%) rename src/features/{crm => crm-demo}/components/customer-detail.tsx (96%) rename src/features/{crm => crm-demo}/components/customer-form-sheet.tsx (95%) create mode 100644 src/features/crm-demo/components/customers-table.tsx create mode 100644 src/features/crm-demo/components/enquiries-table.tsx rename src/features/{crm => crm-demo}/components/enquiry-detail.tsx (100%) rename src/features/{crm => crm-demo}/components/quotation-detail.tsx (98%) create mode 100644 src/features/crm-demo/components/quotations-table.tsx rename src/features/{crm => crm-demo}/components/settings-page.tsx (92%) rename src/features/{crm => crm-demo}/components/shared.tsx (87%) create mode 100644 src/features/crm-demo/data/mock-crm.ts rename src/features/{crm => crm-demo}/data/options.ts (100%) rename src/features/{crm => crm-demo}/schemas/customer.ts (100%) rename src/features/{crm => crm-demo}/utils/format.ts (100%) rename src/features/{crm => crm-demo}/utils/status.ts (100%) delete mode 100644 src/features/crm/components/customers-table.tsx delete mode 100644 src/features/crm/components/enquiries-table.tsx delete mode 100644 src/features/crm/components/quotations-table.tsx delete mode 100644 src/features/crm/data/mock-crm.ts create mode 100644 src/features/foundation/components/crm-production-placeholder.tsx diff --git a/docs/adr/ADR-0006-branch-domain-model.md b/docs/adr/ADR-0006-branch-domain-model.md new file mode 100644 index 0000000..a78d24c --- /dev/null +++ b/docs/adr/ADR-0006-branch-domain-model.md @@ -0,0 +1,8 @@ +Current: +branch stored in ms_options + +Future: +organization_branches table + +Reason: +branch will contain business metadata and workflow configuration \ No newline at end of file diff --git a/docs/implementation/task-b-template-audit.md b/docs/implementation/task-b-template-audit.md new file mode 100644 index 0000000..ff8fde8 --- /dev/null +++ b/docs/implementation/task-b-template-audit.md @@ -0,0 +1,337 @@ +# Task B Template Audit + +## 1. Executive Summary + +Task B implemented the shared foundation layer for ALLA OS CRM vNext without introducing CRM business modules such as Customer, Contact, Enquiry, Quotation, Approval, KPI, Report, or PDF generation. + +The foundation now provides: + +- Current user context helpers +- Organization context helpers +- Permission helpers +- Branch scope abstraction +- Master options service and API +- Document sequence service +- Audit log helper + +This work follows the Task A convention freeze: + +- `organizationId` remains the tenant boundary +- membership and permissions remain organization-scoped +- `branchId` is treated as business sub-scope only +- existing Auth.js and shared auth helpers are reused rather than replaced + +## 2. Files Added + +### Documentation + +- `docs/implementation/task-b-template-audit.md` + +### Schema / Migration + +- `drizzle/0001_orange_mandarin.sql` +- `drizzle/meta/0001_snapshot.json` + +### Foundation Helpers and Services + +- `src/features/foundation/auth-context/types.ts` +- `src/features/foundation/auth-context/service.ts` +- `src/features/foundation/organization-context/service.ts` +- `src/features/foundation/permission/service.ts` +- `src/features/foundation/branch-scope/types.ts` +- `src/features/foundation/branch-scope/service.ts` +- `src/features/foundation/master-options/types.ts` +- `src/features/foundation/master-options/service.ts` +- `src/features/foundation/master-options/api/types.ts` +- `src/features/foundation/master-options/api/service.ts` +- `src/features/foundation/master-options/api/queries.ts` +- `src/features/foundation/master-options/api/mutations.ts` +- `src/features/foundation/master-options/components/columns.tsx` +- `src/features/foundation/master-options/components/master-options-table.tsx` +- `src/features/foundation/master-options/components/master-options-listing.tsx` +- `src/features/foundation/document-sequence/types.ts` +- `src/features/foundation/document-sequence/service.ts` +- `src/features/foundation/audit-log/types.ts` +- `src/features/foundation/audit-log/service.ts` + +### API Route + +- `src/app/api/foundation/master-options/route.ts` + +## 3. Files Modified + +- `src/db/schema.ts` +- `src/app/dashboard/crm/settings/master-options/page.tsx` +- `drizzle/meta/_journal.json` +- `drizzle/meta/0000_snapshot.json` + +## 4. Current User Context Output + +Implemented in: + +- `src/features/foundation/auth-context/service.ts` + +Helpers added: + +- `getCurrentUser()` +- `requireCurrentUser()` +- `getCurrentUserContext()` + +Returned context shape: + +- `user` +- `activeOrganization` +- `membership` +- `permissions` +- `businessRole` + +Implementation notes: + +- Reuses `auth()` from `src/auth.ts` +- Reuses `requireSession()` from `src/lib/auth/session.ts` +- Does not duplicate sign-in or session enrichment logic + +## 5. Organization Context Output + +Implemented in: + +- `src/features/foundation/organization-context/service.ts` + +Helpers added: + +- `getCurrentOrganization()` +- `getActiveOrganizationId()` +- `requireOrganizationAccess()` + +Implementation notes: + +- Re-exports the existing shared `requireOrganizationAccess()` helper +- Reuses current Auth.js active organization state +- Preserves `organizationId` as the only tenant boundary + +## 6. Permission Layer Output + +Implemented in: + +- `src/features/foundation/permission/service.ts` + +Helpers added: + +- `hasPermission(permission)` +- `requirePermission(permission)` +- `hasBusinessRole(role)` + +Implementation notes: + +- Reuses `memberships.permissions` +- Reuses `activeBusinessRole` +- Gives `super_admin` a bypass through existing session semantics +- Does not introduce a second RBAC model + +## 7. Branch Scope Output + +Implemented in: + +- `src/features/foundation/branch-scope/service.ts` + +Helpers added: + +- `getUserBranches()` +- `getActiveBranch()` +- `validateBranchAccess()` + +Implementation notes: + +- Branches are currently abstracted through master options category `crm_branch` +- No CRM branch UI was added +- No direct schema migration for a full branch domain model was added +- Branch remains a business scope, not a tenant model + +## 8. Master Options Output + +Implemented in: + +- `src/features/foundation/master-options/service.ts` +- `src/features/foundation/master-options/api/**` +- `src/app/api/foundation/master-options/route.ts` + +Schema added in: + +- `src/db/schema.ts` as `msOptions` + +Helpers and services added: + +- `listMasterOptions()` +- `getOptionsByCategory()` +- `getActiveOptionsByCategory()` + +Supported characteristics: + +- organization-scoped option records +- category-based lookup +- parent / child option structure +- active / inactive state +- soft delete via `deletedAt` +- reusable query layer for future CRM modules + +Supported category constants: + +- `crm_branch` +- `crm_customer_status` +- `crm_customer_type` +- `crm_enquiry_status` +- `crm_quotation_status` +- `crm_product_type` +- `crm_currency` +- `crm_payment_term` +- `crm_priority` +- `crm_lead_channel` + +## 9. Document Sequence Output + +Implemented in: + +- `src/features/foundation/document-sequence/service.ts` + +Schema added in: + +- `src/db/schema.ts` as `documentSequences` + +Helpers added: + +- `previewNextDocumentCode()` +- `generateNextDocumentCode()` + +Implementation notes: + +- `preview` does not increment +- `generate` increments +- generation uses transaction plus row lock behavior +- code generation stays on the server + +Current default prefixes: + +- `customer -> CUS` +- `contact -> CON` +- `enquiry -> ENQ` +- `quotation -> QT` +- `approval -> APV` + +## 10. Audit Log Output + +Implemented in: + +- `src/features/foundation/audit-log/service.ts` + +Schema added in: + +- `src/db/schema.ts` as `trAuditLogs` + +Helpers added: + +- `auditCreate()` +- `auditUpdate()` +- `auditDelete()` +- `auditAction()` + +Supported fields: + +- `beforeData` +- `afterData` +- `requestId` +- `organizationId` +- `branchId` +- `userId` +- `entityType` +- `entityId` +- `action` + +## 11. UI Output + +Task B only touched the allowed foundation testing page: + +- `src/app/dashboard/crm/settings/master-options/page.tsx` + +What changed: + +- the page no longer points at the old CRM mock reference query +- it now uses the new foundation master options listing flow +- it keeps the existing dashboard shell and `PageContainer` pattern +- it keeps the existing React Query plus DataTable pattern + +What was intentionally not added: + +- no new dashboard shell +- no new design system +- no CRM business forms or CRUD screens + +## 12. Reuse From Template / Existing Repo + +Reused foundation from the current repo: + +- `src/auth.ts` +- `src/lib/auth/session.ts` +- `src/lib/auth/rbac.ts` +- `src/lib/api-client.ts` +- `src/lib/query-client.ts` +- `src/lib/searchparams.ts` +- `PageContainer` +- existing DataTable primitives +- existing React Query hydration pattern +- existing Auth.js session enrichment + +Production reference patterns reused: + +- `src/features/products/**` +- `src/features/users/**` + +## 13. Migration Still Needed + +Future work still required after Task B: + +- seed data or admin flows for `ms_options` +- seed or setup flows for `document_sequences` +- actual CRM entity tables and route handlers +- mutation-level integration that calls `auditCreate`, `auditUpdate`, `auditDelete` +- branch-to-user assignment model if the product needs something stricter than option-based abstraction +- replacement of remaining CRM mock services and pages under `src/features/crm/**` + +## 14. Risks + +Known risks after Task B: + +- the foundation tables exist in schema and migration, but runtime depends on applying the migration +- branch scope is still abstraction-only and not yet backed by a dedicated operational model +- master options page is migrated, but the rest of CRM still points at mock data +- some compile errors still exist in legacy CRM mock files outside the Task B foundation scope + +## 15. Verification Notes + +Completed during Task B: + +- generated Drizzle migration for the new foundation tables +- formatted the new and changed files +- checked TypeScript compilation + +Compilation note: + +- foundation-layer code added for Task B was cleaned up against its own type issues +- project-wide `tsc --noEmit` still reports existing errors in legacy CRM mock files outside the new foundation path + +## 16. Task C Readiness + +Task C can now start on top of this foundation with the following ready: + +- shared user and organization context helpers +- permission guard helpers +- branch access abstraction +- option lookup abstraction for statuses and enumerations +- server-side document code generation +- shared audit logging helper + +Recommended Task C direction: + +- build `Customer` and `Contact` on top of the new foundation services +- keep `organizationId` mandatory on all new CRM entities +- use `branchId` only as business sub-scope where needed +- wire all mutations through sequence and audit helpers where relevant diff --git a/docs/implementation/task-b1-foundation-stabilization.md b/docs/implementation/task-b1-foundation-stabilization.md new file mode 100644 index 0000000..55cf8c0 --- /dev/null +++ b/docs/implementation/task-b1-foundation-stabilization.md @@ -0,0 +1,279 @@ +# Task B.1 Foundation Stabilization + +## 1. Executive Summary + +Task B.1 completed two goals: + +1. Foundation Stabilization +2. Production Isolation + +The foundation layer from Task B is now safer to use for Task C because: + +- foundation seed data now exists for master options and document sequences +- production CRM routes no longer import legacy CRM mock services +- legacy CRM demo code has been isolated under dedicated `crm-demo` paths +- TypeScript compilation passes after the route and module isolation changes + +No Customer, Contact, Enquiry, Quotation, Approval, KPI, Report, PDF, or Notification production module was added in this task. + +## 2. Foundation Review + +### 2.1 Schema Review + +Reviewed tables introduced in Task B: + +- `ms_options` +- `document_sequences` +- `tr_audit_logs` + +Current strengths: + +- all three tables consistently use `organization_id` +- `ms_options` supports soft delete through `deleted_at` +- `ms_options` has a unique constraint on `(organization_id, category, code)` +- `document_sequences` has a unique constraint on `(organization_id, document_type, period, branch_id)` +- `tr_audit_logs` supports `before_data`, `after_data`, and `request_id` + +Current migration notes: + +- `ms_options` does not yet enforce a foreign key from `parent_id` back to `ms_options.id` +- `document_sequences` does not yet enforce a foreign key to a branch model because branch is still abstraction-only +- `tr_audit_logs` does not yet enforce foreign keys to `organizations` or `users` +- `tr_audit_logs` could benefit from future read-oriented indexes such as `(organization_id, entity_type, entity_id)` once audit queries are introduced + +Decision: + +- no schema change was made in Task B.1 for the above notes because the task explicitly asked not to change schema unless necessary + +### 2.2 Service Review + +Reviewed foundation services: + +- `auth-context` +- `organization-context` +- `permission` +- `branch-scope` +- `master-options` +- `document-sequence` +- `audit-log` + +Review summary: + +- naming is consistent with Task B expectations +- return shapes are now stable enough for Task C +- organization-first design is preserved across helpers +- error handling is explicit in permission, organization, branch, and seed flows + +Implementation adjustments made in Task B.1: + +- `searchParams` was extended so isolated demo routes no longer break type checks +- `crm-demo` compile issues were cleaned up without changing its business behavior +- production CRM route tree now uses placeholder or foundation-safe pages only + +## 3. Seed Review + +### 3.1 Foundation Seed Added + +Seed script added: + +- `src/db/seeds/foundation.seed.ts` + +Package scripts updated: + +- `seed:foundation` +- `setup:db` now includes `seed:foundation` + +Seed behavior: + +- loads `DATABASE_URL` from `.env.local` or `.env` +- reads `organizations` from the database +- throws a clear error if no organizations exist +- seeds foundation data for every organization found + +### 3.2 Master Options Seeded + +Seeded categories: + +- `crm_branch` +- `crm_customer_status` +- `crm_customer_type` +- `crm_enquiry_status` +- `crm_quotation_status` +- `crm_product_type` +- `crm_currency` +- `crm_payment_term` +- `crm_priority` +- `crm_lead_channel` + +Important notes: + +- seeding is idempotent via `on conflict` +- `crm_branch` options are seeded first and reused by document sequence seed +- no organization id is hardcoded + +### 3.3 Document Sequence Seeded + +Seeded sequence types: + +- `customer -> CUS` +- `contact -> CON` +- `enquiry -> ENQ` +- `quotation -> QT` +- `approval -> APV` + +Behavior: + +- seeded per organization +- seeded per seeded branch option +- idempotent via `on conflict` +- uses the current `YYMM` period +- throws clearly if required organization or branch data is unavailable + +## 4. Production Isolation Review + +### 4.1 Production-Ready Path + +Production-safe CRM route path now lives under: + +- `src/app/dashboard/crm/**` + +Current production-safe routes: + +- `/dashboard/crm` +- `/dashboard/crm/customers` +- `/dashboard/crm/customers/[id]` +- `/dashboard/crm/enquiries` +- `/dashboard/crm/enquiries/[id]` +- `/dashboard/crm/quotations` +- `/dashboard/crm/quotations/[id]` +- `/dashboard/crm/approvals` +- `/dashboard/crm/settings/document-sequences` +- `/dashboard/crm/settings/templates` +- `/dashboard/crm/settings/master-options` + +Production-safe behavior: + +- no route above imports `@/features/crm-demo/**` +- no route above imports mock query/service layers +- `master-options` remains connected to the real foundation path +- all other CRM production routes are explicit placeholders until Task C and later tasks build real modules + +### 4.2 Legacy Mock Path + +Legacy CRM mock module was moved to: + +- `src/features/crm-demo/**` + +Legacy demo route tree was moved to: + +- `src/app/dashboard/crm-demo/**` + +The old mock flow is still preserved for reference and manual comparison, but it is no longer the production path. + +### 4.3 Route Separation + +Production route: + +- `/dashboard/crm` + +Demo route: + +- `/dashboard/crm-demo` + +Separation achieved: + +- production route tree does not import demo services +- demo route tree imports `@/features/crm-demo/**` +- demo links inside the moved module now point to `/dashboard/crm-demo/**` + +## 5. Mock Modules Moved + +Moved feature module: + +- `src/features/crm` -> `src/features/crm-demo` + +Moved route tree: + +- `src/app/dashboard/crm` legacy implementation -> `src/app/dashboard/crm-demo` + +Then recreated: + +- `src/app/dashboard/crm/**` as production-safe placeholder/foundation routes + +## 6. Files Added + +### Documentation + +- `docs/implementation/task-b1-foundation-stabilization.md` + +### Foundation Seed + +- `src/db/seeds/foundation.seed.ts` + +### Production CRM Placeholder Support + +- `src/features/foundation/components/crm-production-placeholder.tsx` + +### Production CRM Routes + +- `src/app/dashboard/crm/page.tsx` +- `src/app/dashboard/crm/customers/page.tsx` +- `src/app/dashboard/crm/customers/[id]/page.tsx` +- `src/app/dashboard/crm/enquiries/page.tsx` +- `src/app/dashboard/crm/enquiries/[id]/page.tsx` +- `src/app/dashboard/crm/quotations/page.tsx` +- `src/app/dashboard/crm/quotations/[id]/page.tsx` +- `src/app/dashboard/crm/approvals/page.tsx` +- `src/app/dashboard/crm/settings/document-sequences/page.tsx` +- `src/app/dashboard/crm/settings/templates/page.tsx` +- `src/app/dashboard/crm/settings/master-options/page.tsx` + +### Demo Route / Feature Path Created By Move + +- `src/app/dashboard/crm-demo/**` +- `src/features/crm-demo/**` + +## 7. Files Modified + +- `package.json` +- `src/lib/searchparams.ts` +- `src/features/crm-demo/api/service.ts` +- `src/features/crm-demo/components/customers-table.tsx` +- `src/features/crm-demo/components/enquiries-table.tsx` +- `src/features/crm-demo/components/quotations-table.tsx` + +## 8. Production Routes Verified + +Verification result: + +- production `/dashboard/crm/**` no longer depends on legacy CRM mock service imports +- `master-options` is the only CRM route currently wired to a real foundation-backed data path +- all remaining production CRM routes are intentionally placeholders so Task C can start cleanly + +## 9. Remaining Risks + +- production CRM modules still need real tables and route handlers before the placeholders can be replaced +- `tr_audit_logs` has no foreign keys or query indexes yet +- branch scope is still option-based abstraction and not a dedicated branch domain model +- document sequences are seeded by branch option id, so future branch modeling must preserve or migrate that relationship carefully +- `crm-demo` still contains legacy mock business logic by design, even though it is isolated from the production path + +## 10. Task C Readiness + +Task C can begin safely now because: + +- production route tree is no longer tied to mock CRM state +- foundation seed data can be created repeatably +- document sequences can be initialized for real organizations +- production CRM paths are reserved for organization-first implementation +- TypeScript compilation passes after the isolation work + +Recommended Task C entry point: + +- build `Customer` and `Contact` on top of: + - organization context + - permission helpers + - branch abstraction + - master options + - document sequence helper + - audit log helper diff --git a/package.json b/package.json index dc6680a..35a4349 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "migrate": "npx drizzle-kit migrate", "studio": "npx drizzle-kit studio", "seed:super-admin": "node scripts/seed-super-admin.js", - "setup:db": "npm run migrate && npm run seed:super-admin" + "seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts", + "setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -58,6 +59,7 @@ "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", "@tanstack/react-table": "^8.21.3", + "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -92,8 +94,7 @@ "uuid": "^11.1.0", "vaul": "^1.1.2", "zod": "^4.3.6", - "zustand": "^5.0.12", - "bcryptjs": "^3.0.2" + "zustand": "^5.0.12" }, "devDependencies": { "@faker-js/faker": "^9.9.0", diff --git a/plans/task-b.1.md b/plans/task-b.1.md new file mode 100644 index 0000000..fded7dd --- /dev/null +++ b/plans/task-b.1.md @@ -0,0 +1,468 @@ +# Task B.1 – Foundation Stabilization & Production Isolation + +## Context + +Task B Foundation Layer เสร็จแล้ว + +ปัจจุบันระบบมี: + +* Organization Context +* Membership Context +* Permission Layer +* Branch Scope +* Master Options +* Document Sequence +* Audit Log Helper + +เป้าหมายของ Task B.1 คือทำให้ Foundation พร้อมใช้งานจริงก่อนเริ่ม Task C (Customer + Contact) + +--- + +# สิ่งที่ต้องอ่านก่อน + +อ่านและยึดตาม: + +```txt +docs/implementation/task-a-template-audit.md +docs/implementation/task-b-template-audit.md + +Layout.md +AGENTS.md + +.agents/skills/kiranism-shadcn-dashboard + +src/auth.ts +src/lib/auth/** +src/db/schema.ts + +src/features/products/** +src/features/users/** +``` + +ใช้ products และ users เป็น production reference + +--- + +# เป้าหมาย + +ทำ 2 เรื่องหลัก: + +1. Foundation Stabilization +2. Production Isolation + +โดยยังไม่เริ่ม Customer Module + +--- + +# Part A: Foundation Stabilization + +## A.1 Review Foundation Tables + +ตรวจสอบ schema ใหม่ที่เพิ่มจาก Task B + +```txt +msOptions +documentSequences +trAuditLogs +``` + +ตรวจสอบ: + +* unique constraint +* indexes +* foreign keys +* organizationId consistency +* soft delete support + +หากพบจุดที่ควรปรับปรุง + +ให้เขียนเป็น Migration Note + +ห้ามแก้ schema โดยไม่จำเป็น + +--- + +## A.2 Create Foundation Seed + +สร้าง seed script + +ตัวอย่าง path: + +```txt +src/db/seeds/foundation.seed.ts +``` + +Seed เฉพาะ foundation data + +### crm_branch + +```txt +สำนักงานใหญ่ +Factory +Service +``` + +### crm_customer_status + +```txt +active +inactive +suspended +``` + +### crm_customer_type + +```txt +company +individual +``` + +### crm_enquiry_status + +```txt +new +qualifying +requirement +follow_up +converted +closed_lost +cancelled +``` + +### crm_quotation_status + +```txt +draft +pending_approval +approved +rejected +sent +accepted +lost +cancelled +revised +``` + +### crm_product_type + +```txt +crane +dockdoor +solarcell +``` + +### crm_currency + +```txt +THB +USD +EUR +``` + +### crm_payment_term + +```txt +cash +credit_30 +credit_60 +``` + +### crm_priority + +```txt +low +normal +high +urgent +``` + +### crm_lead_channel + +```txt +website +referral +sales +facebook +line +other +``` + +--- + +## A.3 Seed Document Sequences + +Seed initial document types + +```txt +customer +contact +enquiry +quotation +approval +``` + +Mapping + +```txt +CUS +CON +ENQ +QT +APV +``` + +Requirements: + +* idempotent +* run ซ้ำได้ +* ไม่สร้างข้อมูลซ้ำ +* ใช้ organizationId จาก database +* ห้าม hardcode organizationId + +หากไม่พบ organization + +ให้ throw error พร้อมข้อความชัดเจน + +--- + +## A.4 Verify Foundation Services + +ตรวจสอบ service ที่สร้างใน Task B + +```txt +auth-context +organization-context +permission +branch-scope +master-options +document-sequence +audit-log +``` + +ตรวจสอบ: + +* naming consistency +* return type consistency +* error handling +* organization-first design + +หากพบปัญหา + +ให้แก้เฉพาะ foundation layer + +--- + +# Part B: Production Isolation + +## เป้าหมาย + +แยก legacy CRM mock ออกจาก production path + +เพื่อให้ Task C เริ่มบน production architecture เท่านั้น + +--- + +## B.1 Audit Existing CRM Module + +ตรวจสอบ + +```txt +src/features/crm/** +src/app/dashboard/crm/** +``` + +จำแนกเป็น: + +### Production-ready + +ใช้ต่อได้ + +### Legacy Mock + +ยังใช้ + +* mock data +* in-memory state +* placeholder service +* fake API + +--- + +## B.2 Move Mock Out of Production Path + +ห้ามลบไฟล์ + +ให้ย้ายไป: + +```txt +src/features/crm-mock/** +``` + +หรือ + +```txt +src/features/crm-demo/** +``` + +แล้วแต่ convention ที่เหมาะกับโปรเจ็ค + +--- + +## B.3 Route Isolation + +Production route: + +```txt +/dashboard/crm +``` + +Mock route: + +```txt +/dashboard/crm-demo +``` + +Requirements: + +* ห้าม production route import mock service +* ห้าม production route import mock API +* ห้าม production component พึ่ง mock state + +--- + +## B.4 Import Audit + +ตรวจสอบ import ทั้งระบบ + +ค้นหา: + +```txt +mock-api +mock-api-users +crm mock service +fake data +in-memory store +``` + +ถ้ายังถูก import ใน production path + +ให้แก้ + +--- + +## B.5 Compile Error Cleanup + +แก้เฉพาะ: + +```txt +type errors +missing imports +unused exports +broken references +``` + +ห้าม: + +* เปลี่ยน business flow +* rewrite CRM module +* เริ่ม Customer production + +--- + +# Deliverables + +สร้างเอกสาร + +```txt +docs/implementation/task-b1-foundation-stabilization.md +``` + +ต้องมี: + +## Foundation Review + +* schema review +* seed review +* service review + +## Production Isolation Review + +* production modules +* demo modules +* route separation + +## Risks + +* remaining mock dependencies +* migration concerns + +## Task C Readiness + +--- + +# ห้ามทำ + +```txt +Customer CRUD +Contact CRUD +Enquiry CRUD +Quotation CRUD +Approval Workflow +Dashboard KPI +PDF Generator +Reporting +Notification +``` + +--- + +# ห้ามรัน + +```txt +npm run build +npm run lint +npm run test +pnpm build +pnpm lint +pnpm test +``` + +จนกว่าจะได้รับคำสั่ง + +--- + +# Output ที่ต้องการ + +หลังทำเสร็จให้สรุป: + +1. Files Added +2. Files Modified +3. Seed Scripts Added +4. Mock Modules Moved +5. Production Routes Verified +6. Remaining Risks +7. Task C Readiness + +--- + +# Definition of Done + +✔ Foundation tables reviewed + +✔ Foundation seed script created + +✔ Document sequence seed created + +✔ Foundation services reviewed + +✔ Mock CRM isolated from production path + +✔ Production route no longer depends on mock services + +✔ Compile issues related to isolation fixed + +✔ Task C can begin safely + +✔ No Customer module created + +✔ No Quotation module created diff --git a/src/app/dashboard/crm-demo/approvals/page.tsx b/src/app/dashboard/crm-demo/approvals/page.tsx new file mode 100644 index 0000000..9940483 --- /dev/null +++ b/src/app/dashboard/crm-demo/approvals/page.tsx @@ -0,0 +1,26 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { approvalsQueryOptions, crmReferenceQueryOptions } from '@/features/crm-demo/api/queries'; +import { ApprovalsPage } from '@/features/crm-demo/components/approvals-page'; +import { getQueryClient } from '@/lib/query-client'; + +export const metadata = { + title: 'Dashboard: CRM Approvals' +}; + +export default function ApprovalsRoute() { + const queryClient = getQueryClient(); + void queryClient.prefetchQuery(crmReferenceQueryOptions()); + void queryClient.prefetchQuery(approvalsQueryOptions({ page: 1, limit: 10 })); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm-demo/customers/[id]/page.tsx b/src/app/dashboard/crm-demo/customers/[id]/page.tsx new file mode 100644 index 0000000..8a3b1e1 --- /dev/null +++ b/src/app/dashboard/crm-demo/customers/[id]/page.tsx @@ -0,0 +1,24 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { customerByIdOptions } from '@/features/crm-demo/api/queries'; +import { CustomerDetailPage } from '@/features/crm-demo/components/customer-detail'; +import { getQueryClient } from '@/lib/query-client'; + +type PageProps = { params: Promise<{ id: string }> }; + +export default async function CustomerDetailRoute({ params }: PageProps) { + const { id } = await params; + const queryClient = getQueryClient(); + void queryClient.prefetchQuery(customerByIdOptions(id)); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm-demo/customers/page.tsx b/src/app/dashboard/crm-demo/customers/page.tsx new file mode 100644 index 0000000..060dabc --- /dev/null +++ b/src/app/dashboard/crm-demo/customers/page.tsx @@ -0,0 +1,49 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import type { SearchParams } from 'nuqs/server'; +import PageContainer from '@/components/layout/page-container'; +import { crmReferenceQueryOptions, customersQueryOptions } from '@/features/crm-demo/api/queries'; +import { CustomersTablePage } from '@/features/crm-demo/components/customers-table'; +import { getQueryClient } from '@/lib/query-client'; +import { searchParamsCache } from '@/lib/searchparams'; + +export const metadata = { + title: 'Dashboard: CRM Customers' +}; + +export default async function CustomersRoute({ + searchParams +}: { + searchParams: Promise; +}) { + searchParamsCache.parse(await searchParams); + const page = searchParamsCache.get('page'); + const perPage = searchParamsCache.get('perPage'); + const search = searchParamsCache.get('name'); + const status = searchParamsCache.get('customerStatus'); + const branch = searchParamsCache.get('branch'); + const sort = searchParamsCache.get('sort'); + const queryClient = getQueryClient(); + + void queryClient.prefetchQuery(crmReferenceQueryOptions()); + void queryClient.prefetchQuery( + customersQueryOptions({ + page, + limit: perPage, + ...(search && { search }), + ...(status && { status }), + ...(branch && { branch }), + ...(sort && { sort }) + }) + ); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm-demo/enquiries/[id]/page.tsx b/src/app/dashboard/crm-demo/enquiries/[id]/page.tsx new file mode 100644 index 0000000..58e7cf6 --- /dev/null +++ b/src/app/dashboard/crm-demo/enquiries/[id]/page.tsx @@ -0,0 +1,24 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { enquiryByIdOptions } from '@/features/crm-demo/api/queries'; +import { EnquiryDetailPage } from '@/features/crm-demo/components/enquiry-detail'; +import { getQueryClient } from '@/lib/query-client'; + +type PageProps = { params: Promise<{ id: string }> }; + +export default async function EnquiryDetailRoute({ params }: PageProps) { + const { id } = await params; + const queryClient = getQueryClient(); + void queryClient.prefetchQuery(enquiryByIdOptions(id)); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm-demo/enquiries/page.tsx b/src/app/dashboard/crm-demo/enquiries/page.tsx new file mode 100644 index 0000000..c646179 --- /dev/null +++ b/src/app/dashboard/crm-demo/enquiries/page.tsx @@ -0,0 +1,55 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import type { SearchParams } from 'nuqs/server'; +import PageContainer from '@/components/layout/page-container'; +import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm-demo/api/queries'; +import { EnquiriesTablePage } from '@/features/crm-demo/components/enquiries-table'; +import { getQueryClient } from '@/lib/query-client'; +import { searchParamsCache } from '@/lib/searchparams'; + +export const metadata = { + title: 'Dashboard: CRM Enquiries' +}; + +export default async function EnquiriesRoute({ + searchParams +}: { + searchParams: Promise; +}) { + searchParamsCache.parse(await searchParams); + const page = searchParamsCache.get('page'); + const perPage = searchParamsCache.get('perPage'); + const search = searchParamsCache.get('name'); + const status = searchParamsCache.get('status'); + const productType = searchParamsCache.get('productType'); + const salesman = searchParamsCache.get('salesman'); + const dateFrom = searchParamsCache.get('dateFrom'); + const dateTo = searchParamsCache.get('dateTo'); + const sort = searchParamsCache.get('sort'); + const queryClient = getQueryClient(); + + void queryClient.prefetchQuery(crmReferenceQueryOptions()); + void queryClient.prefetchQuery( + enquiriesQueryOptions({ + page, + limit: perPage, + ...(search && { search }), + ...(status && { status }), + ...(productType && { productType }), + ...(salesman && { salesman }), + ...(dateFrom && { dateFrom }), + ...(dateTo && { dateTo }), + ...(sort && { sort }) + }) + ); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm-demo/page.tsx b/src/app/dashboard/crm-demo/page.tsx new file mode 100644 index 0000000..80db018 --- /dev/null +++ b/src/app/dashboard/crm-demo/page.tsx @@ -0,0 +1,25 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { getQueryClient } from '@/lib/query-client'; +import { crmDashboardQueryOptions } from '@/features/crm-demo/api/queries'; +import { CrmDashboardPage } from '@/features/crm-demo/components/crm-dashboard'; + +export const metadata = { + title: 'Dashboard: CRM' +}; + +export default function CrmDashboardRoute() { + const queryClient = getQueryClient(); + void queryClient.prefetchQuery(crmDashboardQueryOptions()); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm-demo/quotations/[id]/page.tsx b/src/app/dashboard/crm-demo/quotations/[id]/page.tsx new file mode 100644 index 0000000..1bbb977 --- /dev/null +++ b/src/app/dashboard/crm-demo/quotations/[id]/page.tsx @@ -0,0 +1,24 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { quotationByIdOptions } from '@/features/crm-demo/api/queries'; +import { QuotationDetailPage } from '@/features/crm-demo/components/quotation-detail'; +import { getQueryClient } from '@/lib/query-client'; + +type PageProps = { params: Promise<{ id: string }> }; + +export default async function QuotationDetailRoute({ params }: PageProps) { + const { id } = await params; + const queryClient = getQueryClient(); + void queryClient.prefetchQuery(quotationByIdOptions(id)); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm-demo/quotations/page.tsx b/src/app/dashboard/crm-demo/quotations/page.tsx new file mode 100644 index 0000000..ec3d781 --- /dev/null +++ b/src/app/dashboard/crm-demo/quotations/page.tsx @@ -0,0 +1,57 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import type { SearchParams } from 'nuqs/server'; +import PageContainer from '@/components/layout/page-container'; +import { crmReferenceQueryOptions, quotationsQueryOptions } from '@/features/crm-demo/api/queries'; +import { QuotationsTablePage } from '@/features/crm-demo/components/quotations-table'; +import { getQueryClient } from '@/lib/query-client'; +import { searchParamsCache } from '@/lib/searchparams'; + +export const metadata = { + title: 'Dashboard: CRM Quotations' +}; + +export default async function QuotationsRoute({ + searchParams +}: { + searchParams: Promise; +}) { + searchParamsCache.parse(await searchParams); + const page = searchParamsCache.get('page'); + const perPage = searchParamsCache.get('perPage'); + const search = searchParamsCache.get('name'); + const status = searchParamsCache.get('status'); + const quotationType = searchParamsCache.get('quotationType'); + const salesman = searchParamsCache.get('salesman'); + const hot = searchParamsCache.get('hot'); + const dateFrom = searchParamsCache.get('dateFrom'); + const dateTo = searchParamsCache.get('dateTo'); + const sort = searchParamsCache.get('sort'); + const queryClient = getQueryClient(); + + void queryClient.prefetchQuery(crmReferenceQueryOptions()); + void queryClient.prefetchQuery( + quotationsQueryOptions({ + page, + limit: perPage, + ...(search && { search }), + ...(status && { status }), + ...(quotationType && { quotationType }), + ...(salesman && { salesman }), + ...(hot && { hot }), + ...(dateFrom && { dateFrom }), + ...(dateTo && { dateTo }), + ...(sort && { sort }) + }) + ); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm-demo/settings/document-sequences/page.tsx b/src/app/dashboard/crm-demo/settings/document-sequences/page.tsx new file mode 100644 index 0000000..900f81c --- /dev/null +++ b/src/app/dashboard/crm-demo/settings/document-sequences/page.tsx @@ -0,0 +1,21 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { crmReferenceQueryOptions } from '@/features/crm-demo/api/queries'; +import { DocumentSequencesPage } from '@/features/crm-demo/components/settings-page'; +import { getQueryClient } from '@/lib/query-client'; + +export default function DocumentSequencesRoute() { + const queryClient = getQueryClient(); + void queryClient.prefetchQuery(crmReferenceQueryOptions()); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm-demo/settings/master-options/page.tsx b/src/app/dashboard/crm-demo/settings/master-options/page.tsx new file mode 100644 index 0000000..5a35fa7 --- /dev/null +++ b/src/app/dashboard/crm-demo/settings/master-options/page.tsx @@ -0,0 +1,37 @@ +import { auth } from '@/auth'; +import PageContainer from '@/components/layout/page-container'; +import MasterOptionsListingPage from '@/features/foundation/master-options/components/master-options-listing'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { searchParamsCache } from '@/lib/searchparams'; +import type { SearchParams } from 'nuqs/server'; + +type PageProps = { + searchParams: Promise; +}; + +export default async function MasterOptionsRoute(props: PageProps) { + const searchParams = await props.searchParams; + const session = await auth(); + const canManageOptions = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.organizationManage))); + + searchParamsCache.parse(searchParams); + + return ( + + You do not have access to master option management. + + } + > + + + ); +} diff --git a/src/app/dashboard/crm-demo/settings/templates/page.tsx b/src/app/dashboard/crm-demo/settings/templates/page.tsx new file mode 100644 index 0000000..dc4a746 --- /dev/null +++ b/src/app/dashboard/crm-demo/settings/templates/page.tsx @@ -0,0 +1,21 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { crmReferenceQueryOptions } from '@/features/crm-demo/api/queries'; +import { TemplatesPage } from '@/features/crm-demo/components/settings-page'; +import { getQueryClient } from '@/lib/query-client'; + +export default function TemplatesRoute() { + const queryClient = getQueryClient(); + void queryClient.prefetchQuery(crmReferenceQueryOptions()); + + return ( + + + + + + ); +} diff --git a/src/app/dashboard/crm/approvals/page.tsx b/src/app/dashboard/crm/approvals/page.tsx index 80893a6..6a3ceca 100644 --- a/src/app/dashboard/crm/approvals/page.tsx +++ b/src/app/dashboard/crm/approvals/page.tsx @@ -1,26 +1,28 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { approvalsQueryOptions, crmReferenceQueryOptions } from '@/features/crm/api/queries'; -import { ApprovalsPage } from '@/features/crm/components/approvals-page'; -import { getQueryClient } from '@/lib/query-client'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; export const metadata = { title: 'Dashboard: CRM Approvals' }; export default function ApprovalsRoute() { - const queryClient = getQueryClient(); - void queryClient.prefetchQuery(crmReferenceQueryOptions()); - void queryClient.prefetchQuery(approvalsQueryOptions({ page: 1, limit: 10 })); - return ( - - - + ); } diff --git a/src/app/dashboard/crm/customers/[id]/page.tsx b/src/app/dashboard/crm/customers/[id]/page.tsx index a165bf8..8ab2eb0 100644 --- a/src/app/dashboard/crm/customers/[id]/page.tsx +++ b/src/app/dashboard/crm/customers/[id]/page.tsx @@ -1,24 +1,30 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { customerByIdOptions } from '@/features/crm/api/queries'; -import { CustomerDetailPage } from '@/features/crm/components/customer-detail'; -import { getQueryClient } from '@/lib/query-client'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; -type PageProps = { params: Promise<{ id: string }> }; +type PageProps = { + params: Promise<{ id: string }>; +}; export default async function CustomerDetailRoute({ params }: PageProps) { const { id } = await params; - const queryClient = getQueryClient(); - void queryClient.prefetchQuery(customerByIdOptions(id)); return ( - - - + ); } diff --git a/src/app/dashboard/crm/customers/page.tsx b/src/app/dashboard/crm/customers/page.tsx index f033720..894270f 100644 --- a/src/app/dashboard/crm/customers/page.tsx +++ b/src/app/dashboard/crm/customers/page.tsx @@ -1,49 +1,29 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; -import type { SearchParams } from 'nuqs/server'; import PageContainer from '@/components/layout/page-container'; -import { crmReferenceQueryOptions, customersQueryOptions } from '@/features/crm/api/queries'; -import { CustomersTablePage } from '@/features/crm/components/customers-table'; -import { getQueryClient } from '@/lib/query-client'; -import { searchParamsCache } from '@/lib/searchparams'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; export const metadata = { title: 'Dashboard: CRM Customers' }; -export default async function CustomersRoute({ - searchParams -}: { - searchParams: Promise; -}) { - searchParamsCache.parse(await searchParams); - const page = searchParamsCache.get('page'); - const perPage = searchParamsCache.get('perPage'); - const search = searchParamsCache.get('name'); - const status = searchParamsCache.get('customerStatus'); - const branch = searchParamsCache.get('branch'); - const sort = searchParamsCache.get('sort'); - const queryClient = getQueryClient(); - - void queryClient.prefetchQuery(crmReferenceQueryOptions()); - void queryClient.prefetchQuery( - customersQueryOptions({ - page, - limit: perPage, - ...(search && { search }), - ...(status && { status }), - ...(branch && { branch }), - ...(sort && { sort }) - }) - ); - +export default function CustomersRoute() { return ( - - - + ); } diff --git a/src/app/dashboard/crm/enquiries/[id]/page.tsx b/src/app/dashboard/crm/enquiries/[id]/page.tsx index cadfd7e..fa9e30d 100644 --- a/src/app/dashboard/crm/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm/enquiries/[id]/page.tsx @@ -1,24 +1,30 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { enquiryByIdOptions } from '@/features/crm/api/queries'; -import { EnquiryDetailPage } from '@/features/crm/components/enquiry-detail'; -import { getQueryClient } from '@/lib/query-client'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; -type PageProps = { params: Promise<{ id: string }> }; +type PageProps = { + params: Promise<{ id: string }>; +}; export default async function EnquiryDetailRoute({ params }: PageProps) { const { id } = await params; - const queryClient = getQueryClient(); - void queryClient.prefetchQuery(enquiryByIdOptions(id)); return ( - - - + ); } diff --git a/src/app/dashboard/crm/enquiries/page.tsx b/src/app/dashboard/crm/enquiries/page.tsx index 17ad3af..e9ff830 100644 --- a/src/app/dashboard/crm/enquiries/page.tsx +++ b/src/app/dashboard/crm/enquiries/page.tsx @@ -1,55 +1,29 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; -import type { SearchParams } from 'nuqs/server'; import PageContainer from '@/components/layout/page-container'; -import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm/api/queries'; -import { EnquiriesTablePage } from '@/features/crm/components/enquiries-table'; -import { getQueryClient } from '@/lib/query-client'; -import { searchParamsCache } from '@/lib/searchparams'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; export const metadata = { title: 'Dashboard: CRM Enquiries' }; -export default async function EnquiriesRoute({ - searchParams -}: { - searchParams: Promise; -}) { - searchParamsCache.parse(await searchParams); - const page = searchParamsCache.get('page'); - const perPage = searchParamsCache.get('perPage'); - const search = searchParamsCache.get('name'); - const status = searchParamsCache.get('status'); - const productType = searchParamsCache.get('productType'); - const salesman = searchParamsCache.get('salesman'); - const dateFrom = searchParamsCache.get('dateFrom'); - const dateTo = searchParamsCache.get('dateTo'); - const sort = searchParamsCache.get('sort'); - const queryClient = getQueryClient(); - - void queryClient.prefetchQuery(crmReferenceQueryOptions()); - void queryClient.prefetchQuery( - enquiriesQueryOptions({ - page, - limit: perPage, - ...(search && { search }), - ...(status && { status }), - ...(productType && { productType }), - ...(salesman && { salesman }), - ...(dateFrom && { dateFrom }), - ...(dateTo && { dateTo }), - ...(sort && { sort }) - }) - ); - +export default function EnquiriesRoute() { return ( - - - + ); } diff --git a/src/app/dashboard/crm/page.tsx b/src/app/dashboard/crm/page.tsx index dc6aefd..c715fc9 100644 --- a/src/app/dashboard/crm/page.tsx +++ b/src/app/dashboard/crm/page.tsx @@ -1,25 +1,31 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { getQueryClient } from '@/lib/query-client'; -import { crmDashboardQueryOptions } from '@/features/crm/api/queries'; -import { CrmDashboardPage } from '@/features/crm/components/crm-dashboard'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; export const metadata = { title: 'Dashboard: CRM' }; export default function CrmDashboardRoute() { - const queryClient = getQueryClient(); - void queryClient.prefetchQuery(crmDashboardQueryOptions()); - return ( - - - + ); } diff --git a/src/app/dashboard/crm/quotations/[id]/page.tsx b/src/app/dashboard/crm/quotations/[id]/page.tsx index ecc0b2f..bec180b 100644 --- a/src/app/dashboard/crm/quotations/[id]/page.tsx +++ b/src/app/dashboard/crm/quotations/[id]/page.tsx @@ -1,24 +1,30 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { quotationByIdOptions } from '@/features/crm/api/queries'; -import { QuotationDetailPage } from '@/features/crm/components/quotation-detail'; -import { getQueryClient } from '@/lib/query-client'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; -type PageProps = { params: Promise<{ id: string }> }; +type PageProps = { + params: Promise<{ id: string }>; +}; export default async function QuotationDetailRoute({ params }: PageProps) { const { id } = await params; - const queryClient = getQueryClient(); - void queryClient.prefetchQuery(quotationByIdOptions(id)); return ( - - - + ); } diff --git a/src/app/dashboard/crm/quotations/page.tsx b/src/app/dashboard/crm/quotations/page.tsx index f1aa26e..059579e 100644 --- a/src/app/dashboard/crm/quotations/page.tsx +++ b/src/app/dashboard/crm/quotations/page.tsx @@ -1,57 +1,28 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; -import type { SearchParams } from 'nuqs/server'; import PageContainer from '@/components/layout/page-container'; -import { crmReferenceQueryOptions, quotationsQueryOptions } from '@/features/crm/api/queries'; -import { QuotationsTablePage } from '@/features/crm/components/quotations-table'; -import { getQueryClient } from '@/lib/query-client'; -import { searchParamsCache } from '@/lib/searchparams'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; export const metadata = { title: 'Dashboard: CRM Quotations' }; -export default async function QuotationsRoute({ - searchParams -}: { - searchParams: Promise; -}) { - searchParamsCache.parse(await searchParams); - const page = searchParamsCache.get('page'); - const perPage = searchParamsCache.get('perPage'); - const search = searchParamsCache.get('name'); - const status = searchParamsCache.get('status'); - const quotationType = searchParamsCache.get('quotationType'); - const salesman = searchParamsCache.get('salesman'); - const hot = searchParamsCache.get('hot'); - const dateFrom = searchParamsCache.get('dateFrom'); - const dateTo = searchParamsCache.get('dateTo'); - const sort = searchParamsCache.get('sort'); - const queryClient = getQueryClient(); - - void queryClient.prefetchQuery(crmReferenceQueryOptions()); - void queryClient.prefetchQuery( - quotationsQueryOptions({ - page, - limit: perPage, - ...(search && { search }), - ...(status && { status }), - ...(quotationType && { quotationType }), - ...(salesman && { salesman }), - ...(hot && { hot }), - ...(dateFrom && { dateFrom }), - ...(dateTo && { dateTo }), - ...(sort && { sort }) - }) - ); - +export default function QuotationsRoute() { return ( - - - + ); } diff --git a/src/app/dashboard/crm/settings/document-sequences/page.tsx b/src/app/dashboard/crm/settings/document-sequences/page.tsx index b3b184c..c4e7f99 100644 --- a/src/app/dashboard/crm/settings/document-sequences/page.tsx +++ b/src/app/dashboard/crm/settings/document-sequences/page.tsx @@ -1,21 +1,23 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { crmReferenceQueryOptions } from '@/features/crm/api/queries'; -import { DocumentSequencesPage } from '@/features/crm/components/settings-page'; -import { getQueryClient } from '@/lib/query-client'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; export default function DocumentSequencesRoute() { - const queryClient = getQueryClient(); - void queryClient.prefetchQuery(crmReferenceQueryOptions()); - return ( - - - + ); } diff --git a/src/app/dashboard/crm/settings/templates/page.tsx b/src/app/dashboard/crm/settings/templates/page.tsx index 7776b50..18bf3c3 100644 --- a/src/app/dashboard/crm/settings/templates/page.tsx +++ b/src/app/dashboard/crm/settings/templates/page.tsx @@ -1,21 +1,19 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { crmReferenceQueryOptions } from '@/features/crm/api/queries'; -import { TemplatesPage } from '@/features/crm/components/settings-page'; -import { getQueryClient } from '@/lib/query-client'; +import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; export default function TemplatesRoute() { - const queryClient = getQueryClient(); - void queryClient.prefetchQuery(crmReferenceQueryOptions()); - return ( - - - + ); } diff --git a/src/app/dashboard/workspaces/page.tsx b/src/app/dashboard/workspaces/page.tsx index c03c12a..0ed4e03 100644 --- a/src/app/dashboard/workspaces/page.tsx +++ b/src/app/dashboard/workspaces/page.tsx @@ -1,14 +1,20 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import PageContainer from '@/components/layout/page-container'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { workspacesInfoContent } from '@/config/infoconfig'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import { toast } from 'sonner'; +import { useEffect, useState } from "react"; +import PageContainer from "@/components/layout/page-container"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { workspacesInfoContent } from "@/config/infoconfig"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; type OrganizationSummary = { id: string; @@ -23,12 +29,12 @@ type OrganizationSummary = { export default function WorkspacesPage() { const { data: session, status, update } = useSession(); const router = useRouter(); - const [workspaceName, setWorkspaceName] = useState(''); + const [workspaceName, setWorkspaceName] = useState(""); const [isCreating, setIsCreating] = useState(false); const [organizations, setOrganizations] = useState([]); const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false); - const canManageOrganizations = session?.user?.systemRole === 'super_admin'; + const canManageOrganizations = session?.user?.systemRole === "super_admin"; useEffect(() => { if (!canManageOrganizations) { @@ -42,14 +48,14 @@ export default function WorkspacesPage() { setIsLoadingOrganizations(true); try { - const response = await fetch('/api/organizations'); + const response = await fetch("/api/organizations"); const data = (await response.json()) as { organizations?: OrganizationSummary[]; message?: string; }; if (!response.ok) { - throw new Error(data.message ?? 'Unable to load organizations'); + throw new Error(data.message ?? "Unable to load organizations"); } if (!cancelled) { @@ -57,7 +63,11 @@ export default function WorkspacesPage() { } } catch (error) { if (!cancelled) { - toast.error(error instanceof Error ? error.message : 'Unable to load organizations'); + toast.error( + error instanceof Error + ? error.message + : "Unable to load organizations", + ); } } finally { if (!cancelled) { @@ -75,30 +85,34 @@ export default function WorkspacesPage() { const createWorkspace = async () => { if (!workspaceName.trim()) { - toast.error('Workspace name is required'); + toast.error("Workspace name is required"); return; } setIsCreating(true); try { - const response = await fetch('/api/organizations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: workspaceName }) + const response = await fetch("/api/organizations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: workspaceName }), }); if (!response.ok) { - const data = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(data?.message ?? 'Unable to create workspace'); + const data = (await response.json().catch(() => null)) as { + message?: string; + } | null; + throw new Error(data?.message ?? "Unable to create workspace"); } - setWorkspaceName(''); + setWorkspaceName(""); await update(); router.refresh(); - toast.success('Workspace created'); + toast.success("Workspace created"); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Unable to create workspace'); + toast.error( + error instanceof Error ? error.message : "Unable to create workspace", + ); } finally { setIsCreating(false); } @@ -106,58 +120,65 @@ export default function WorkspacesPage() { return ( +
Only super admins can manage workspaces.
} > -
+
Your workspaces - The active workspace controls organization-scoped product data and access checks. + The active workspace controls organization-scoped product data and + access checks. - + {organizations.length ? ( organizations.map((organization) => { - const isActive = organization.id === session?.user?.activeOrganizationId; + const isActive = + organization.id === session?.user?.activeOrganizationId; return ( ); }) ) : ( -
- No workspace found yet. Create your first workspace to unlock organization-scoped - product management. +
+ No workspace found yet. Create your first workspace to unlock + organization-scoped product management.
)} @@ -167,23 +188,28 @@ export default function WorkspacesPage() { Create workspace - This flow is limited to super admins in the organization RBAC model. + This flow is limited to super admins in the organization RBAC + model. - -
-
- +
{data.pendingApprovals.map((item) => (
@@ -144,7 +185,9 @@ export function CrmDashboardPage() {

{item.quotationCode}

-

{item.productType} / Approver Level {item.approverLevel}

+

+ {item.productType} / Approver Level {item.approverLevel} +

{formatCurrency(item.amount)}

))} @@ -155,7 +198,11 @@ export function CrmDashboardPage() {
- +
{data.dueFollowUps.map((item) => (
diff --git a/src/features/crm/components/customer-detail.tsx b/src/features/crm-demo/components/customer-detail.tsx similarity index 96% rename from src/features/crm/components/customer-detail.tsx rename to src/features/crm-demo/components/customer-detail.tsx index 6b47980..7eccf09 100644 --- a/src/features/crm/components/customer-detail.tsx +++ b/src/features/crm-demo/components/customer-detail.tsx @@ -133,10 +133,7 @@ export function CustomerDetailPage({ id }: { id: string }) {
{data.shares.length === 0 ? ( - + ) : ( data.shares.map((share) => (
- + - +
diff --git a/src/features/crm-demo/components/customers-table.tsx b/src/features/crm-demo/components/customers-table.tsx new file mode 100644 index 0000000..f3fa896 --- /dev/null +++ b/src/features/crm-demo/components/customers-table.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { type ColumnDef } from '@tanstack/react-table'; +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; +import { getSortingStateParser } from '@/lib/parsers'; +import { useDataTable } from '@/hooks/use-data-table'; +import { DataTable } from '@/components/ui/table/data-table'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Icons } from '@/components/icons'; +import { customersQueryOptions, crmReferenceQueryOptions } from '../api/queries'; +import type { Customer } from '../api/types'; +import { CustomerFormSheet } from './customer-form-sheet'; +import { BranchBadge, CustomerStatusBadge } from './shared'; + +export function CustomersTablePage() { + const [editingCustomer, setEditingCustomer] = useState(); + const [sheetOpen, setSheetOpen] = useState(false); + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'code', + header: 'Code', + cell: ({ row }) => {row.original.code} + }, + { + accessorKey: 'name', + header: 'Customer', + cell: ({ row }) => ( +
+

{row.original.name}

+

{row.original.email}

+
+ ) + }, + { + accessorKey: 'customerStatus', + header: 'Status', + cell: ({ row }) => + }, + { + accessorKey: 'branchId', + header: 'Branch', + cell: ({ row, table }) => { + const branches = ( + table.options.meta as { + branches: Array<{ + id: string; + code: string; + name: string; + region: string; + }>; + } + ).branches; + const branch = branches.find((item) => item.id === row.original.branchId); + return branch ? : null; + } + }, + { + accessorKey: 'contactIds', + header: 'Contacts', + cell: ({ row }) => row.original.contactIds.length + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( +
+ + +
+ ) + } + ], + [] + ); + + const columnIds = ['code', 'name', 'customerStatus', 'branchId', 'contactIds', 'actions']; + + const [params, setParams] = useQueryStates({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + name: parseAsString, + customerStatus: parseAsString, + branch: parseAsString, + sort: getSortingStateParser(columnIds).withDefault([]) + }); + const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions()); + + const filters = { + page: params.page, + limit: params.perPage, + ...(params.name && { search: params.name }), + ...(params.customerStatus && { status: params.customerStatus }), + ...(params.branch && { branch: params.branch }), + ...(params.sort.length ? { 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, + meta: { branches: reference.branches }, + initialState: { + columnPinning: { right: ['actions'] } + } + }); + + return ( +
+
+
+ void setParams({ name: event.target.value || null, page: 1 })} + /> + + +
+ +
+ + + { + setSheetOpen(open); + if (!open) setEditingCustomer(undefined); + }} + customer={editingCustomer} + /> +
+ ); +} diff --git a/src/features/crm-demo/components/enquiries-table.tsx b/src/features/crm-demo/components/enquiries-table.tsx new file mode 100644 index 0000000..ca20eb0 --- /dev/null +++ b/src/features/crm-demo/components/enquiries-table.tsx @@ -0,0 +1,157 @@ +'use client'; + +import Link from 'next/link'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { type ColumnDef } from '@tanstack/react-table'; +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; +import { getSortingStateParser } from '@/lib/parsers'; +import { useDataTable } from '@/hooks/use-data-table'; +import { DataTable } from '@/components/ui/table/data-table'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { crmReferenceQueryOptions, enquiriesQueryOptions } from '../api/queries'; +import { convertEnquiryMutation } from '../api/mutations'; +import type { Enquiry } from '../api/types'; +import { ChancePill, EnquiryStatusBadge } from './shared'; + +const columns: ColumnDef[] = [ + { accessorKey: 'code', header: 'Code' }, + { accessorKey: 'title', header: 'Enquiry' }, + { accessorKey: 'productType', header: 'Product' }, + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => + }, + { + accessorKey: 'chancePercent', + header: 'Chance', + cell: ({ row }) => + }, + { + id: 'actions', + header: '', + cell: ({ row }) => + } +]; + +const columnIds = ['code', 'title', 'productType', 'status', 'chancePercent', 'actions']; + +function RowActions({ enquiry }: { enquiry: Enquiry }) { + const mutation = useMutation({ + ...convertEnquiryMutation, + onSuccess: () => toast.success('สร้าง quotation mock จาก enquiry แล้ว') + }); + + return ( +
+ + +
+ ); +} + +export function EnquiriesTablePage() { + const [params, setParams] = useQueryStates({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + name: parseAsString, + status: parseAsString, + productType: parseAsString, + salesman: parseAsString, + dateFrom: parseAsString, + dateTo: parseAsString, + sort: getSortingStateParser(columnIds).withDefault([]) + }); + const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions()); + + const filters = { + page: params.page, + limit: params.perPage, + ...(params.name && { search: params.name }), + ...(params.status && { status: params.status }), + ...(params.productType && { productType: params.productType }), + ...(params.salesman && { salesman: params.salesman }), + ...(params.dateFrom && { dateFrom: params.dateFrom }), + ...(params.dateTo && { dateTo: params.dateTo }), + ...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}) + }; + + const { data } = useSuspenseQuery(enquiriesQueryOptions(filters)); + const pageCount = Math.ceil(data.totalItems / params.perPage); + const { table } = useDataTable({ + data: data.items, + columns, + pageCount, + initialState: { + columnPinning: { right: ['actions'] } + } + }); + + return ( +
+
+ void setParams({ name: event.target.value || null, page: 1 })} + /> + + + + void setParams({ dateFrom: event.target.value || null, page: 1 })} + /> + void setParams({ dateTo: event.target.value || null, page: 1 })} + /> +
+
+ +
+ +
+ ); +} diff --git a/src/features/crm/components/enquiry-detail.tsx b/src/features/crm-demo/components/enquiry-detail.tsx similarity index 100% rename from src/features/crm/components/enquiry-detail.tsx rename to src/features/crm-demo/components/enquiry-detail.tsx diff --git a/src/features/crm/components/quotation-detail.tsx b/src/features/crm-demo/components/quotation-detail.tsx similarity index 98% rename from src/features/crm/components/quotation-detail.tsx rename to src/features/crm-demo/components/quotation-detail.tsx index bcf0456..32f9ea3 100644 --- a/src/features/crm/components/quotation-detail.tsx +++ b/src/features/crm-demo/components/quotation-detail.tsx @@ -211,9 +211,7 @@ export function QuotationDetailPage({ id }: { id: string }) { Revision R{String(data.quotation.revision).padStart(2, '0')} - - {formatCurrency(data.quotation.totalAmount)} - + {formatCurrency(data.quotation.totalAmount)}
[] = [ + { accessorKey: 'code', header: 'Code' }, + { accessorKey: 'project', header: 'Project' }, + { accessorKey: 'quotationType', header: 'Type' }, + { + accessorKey: 'status', + header: 'Status', + cell: ({ row }) => + }, + { accessorKey: 'revision', header: 'Rev' }, + { + accessorKey: 'totalAmount', + header: 'Amount', + cell: ({ row }) => formatCurrency(row.original.totalAmount) + }, + { + accessorKey: 'isHotProject', + header: 'Hot', + cell: ({ row }) => + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( +
+ +
+ ) + } +]; + +const columnIds = [ + 'code', + 'project', + 'quotationType', + 'status', + 'revision', + 'totalAmount', + 'isHotProject', + 'actions' +]; + +function KanbanView({ items }: { items: Quotation[] }) { + const statuses = ['draft', 'pending_approval', 'approved', 'sent', 'accepted', 'lost']; + + return ( +
+ {statuses.map((status) => ( + +
+

{status.replace('_', ' ')}

+ + {items.filter((item) => item.status === status).length} + +
+ {items + .filter((item) => item.status === status) + .map((item) => ( + +

{item.code}

+

{item.project}

+

{formatCurrency(item.totalAmount)}

+ + ))} +
+ ))} +
+ ); +} + +export function QuotationsTablePage() { + const [params, setParams] = useQueryStates({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + name: parseAsString, + status: parseAsString, + quotationType: parseAsString, + salesman: parseAsString, + hot: parseAsString, + dateFrom: parseAsString, + dateTo: parseAsString, + view: parseAsString.withDefault('table'), + sort: getSortingStateParser(columnIds).withDefault([]) + }); + const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions()); + + const filters = { + page: params.page, + limit: params.perPage, + ...(params.name && { search: params.name }), + ...(params.status && { status: params.status }), + ...(params.quotationType && { quotationType: params.quotationType }), + ...(params.salesman && { salesman: params.salesman }), + ...(params.hot && { hot: params.hot }), + ...(params.dateFrom && { dateFrom: params.dateFrom }), + ...(params.dateTo && { dateTo: params.dateTo }), + ...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}) + }; + + const { data } = useSuspenseQuery(quotationsQueryOptions(filters)); + const pageCount = Math.ceil(data.totalItems / params.perPage); + const { table } = useDataTable({ + data: data.items, + columns, + pageCount, + initialState: { + columnPinning: { right: ['actions'] } + } + }); + + return ( +
+
+
+ void setParams({ name: event.target.value || null, page: 1 })} + /> + + + + + void setParams({ dateFrom: event.target.value || null, page: 1 })} + /> + void setParams({ dateTo: event.target.value || null, page: 1 })} + /> +
+ +
+ + + +
+
+ + {params.view === 'kanban' ? : } +
+ ); +} diff --git a/src/features/crm/components/settings-page.tsx b/src/features/crm-demo/components/settings-page.tsx similarity index 92% rename from src/features/crm/components/settings-page.tsx rename to src/features/crm-demo/components/settings-page.tsx index 09d6000..b445cbd 100644 --- a/src/features/crm/components/settings-page.tsx +++ b/src/features/crm-demo/components/settings-page.tsx @@ -8,7 +8,10 @@ export function MasterOptionsPage() { const { data } = useSuspenseQuery(crmReferenceQueryOptions()); return ( - +
{data.masterOptions.map((item) => (
@@ -32,7 +35,8 @@ export function DocumentSequencesPage() {

- {item.prefix}{item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')} + {item.prefix} + {item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')}

{item.documentType}

diff --git a/src/features/crm/components/shared.tsx b/src/features/crm-demo/components/shared.tsx similarity index 87% rename from src/features/crm/components/shared.tsx rename to src/features/crm-demo/components/shared.tsx index 1a70550..15abb4b 100644 --- a/src/features/crm/components/shared.tsx +++ b/src/features/crm-demo/components/shared.tsx @@ -86,7 +86,11 @@ export function BranchBadge({ branch }: { branch: CrmBranch }) { export function CustomerStatusBadge({ status }: { status: Customer['customerStatus'] }) { return ( - + {getCustomerStatusLabel(status)} ); @@ -132,7 +136,14 @@ export function InfoGrid({ cols?: 2 | 3 | 4; }) { return ( -
+
{items.map((item) => (

{item.label}

@@ -199,7 +210,10 @@ export function CustomerRoleCards({ const contact = contacts.find((item) => item.id === role.contactId); return ( -
+

{role.role}

{customer?.code} @@ -226,7 +240,10 @@ export function QuotationPreviewPanel({ approvalSteps: ApprovalStep[]; }) { return ( - +
@@ -235,9 +252,16 @@ export function QuotationPreviewPanel({

{quotation.project}

-

quotation_date: {quotation.quotationDate}

-

valid_until: {quotation.validUntil}

-

currency: THB

+

+ quotation_date:{' '} + {quotation.quotationDate} +

+

+ valid_until: {quotation.validUntil} +

+

+ currency: THB +

@@ -262,7 +286,10 @@ export function QuotationPreviewPanel({

item_topic

{quotation.items.map((item) => ( -
+

{item.description}

@@ -279,7 +306,9 @@ export function QuotationPreviewPanel({

    {quotation.topics .find((topic) => topic.type === 'exclusion') - ?.items.map((item) =>
  • - {item}
  • )} + ?.items.map((item) => ( +
  • - {item}
  • + ))}
@@ -306,7 +335,10 @@ export function RelatedQuotationList({ quotations }: { quotations: Quotation[] } return (
{quotations.map((quotation) => ( -
+

{quotation.code}

@@ -317,7 +349,7 @@ export function RelatedQuotationList({ quotations }: { quotations: Quotation[] }

{formatCurrency(quotation.totalAmount)}

- -
- ), - }, - ], - [], - ); - - const columnIds = columns - .map((column) => column.id ?? String(column.accessorKey)) - .filter(Boolean); - - const [params, setParams] = useQueryStates({ - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - name: parseAsString, - customerStatus: parseAsString, - branch: parseAsString, - sort: getSortingStateParser(columnIds).withDefault([]), - }); - const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions()); - - const filters = { - page: params.page, - limit: params.perPage, - ...(params.name && { search: params.name }), - ...(params.customerStatus && { status: params.customerStatus }), - ...(params.branch && { branch: params.branch }), - ...(params.sort.length ? { 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, - meta: { branches: reference.branches }, - initialState: { - columnPinning: { right: ["actions"] }, - }, - }); - - return ( -
-
-
- - void setParams({ name: event.target.value || null, page: 1 }) - } - /> - - -
- -
- - - { - setSheetOpen(open); - if (!open) setEditingCustomer(undefined); - }} - customer={editingCustomer} - /> -
- ); -} diff --git a/src/features/crm/components/enquiries-table.tsx b/src/features/crm/components/enquiries-table.tsx deleted file mode 100644 index 15faa15..0000000 --- a/src/features/crm/components/enquiries-table.tsx +++ /dev/null @@ -1,178 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; -import { type ColumnDef } from "@tanstack/react-table"; -import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; -import { getSortingStateParser } from "@/lib/parsers"; -import { useDataTable } from "@/hooks/use-data-table"; -import { DataTable } from "@/components/ui/table/data-table"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { toast } from "sonner"; -import { - crmReferenceQueryOptions, - enquiriesQueryOptions, -} from "../api/queries"; -import { convertEnquiryMutation } from "../api/mutations"; -import type { Enquiry } from "../api/types"; -import { ChancePill, EnquiryStatusBadge } from "./shared"; - -const columns: ColumnDef[] = [ - { accessorKey: "code", header: "Code" }, - { accessorKey: "title", header: "Enquiry" }, - { accessorKey: "productType", header: "Product" }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => , - }, - { - accessorKey: "chancePercent", - header: "Chance", - cell: ({ row }) => , - }, - { - id: "actions", - header: "", - cell: ({ row }) => , - }, -]; - -const columnIds = columns - .map((column) => column.id ?? String(column.accessorKey)) - .filter(Boolean); - -function RowActions({ enquiry }: { enquiry: Enquiry }) { - const mutation = useMutation({ - ...convertEnquiryMutation, - onSuccess: () => toast.success("สร้าง quotation mock จาก enquiry แล้ว"), - }); - - return ( -
- - -
- ); -} - -export function EnquiriesTablePage() { - const [params, setParams] = useQueryStates({ - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - name: parseAsString, - status: parseAsString, - productType: parseAsString, - salesman: parseAsString, - dateFrom: parseAsString, - dateTo: parseAsString, - sort: getSortingStateParser(columnIds).withDefault([]), - }); - const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions()); - - const filters = { - page: params.page, - limit: params.perPage, - ...(params.name && { search: params.name }), - ...(params.status && { status: params.status }), - ...(params.productType && { productType: params.productType }), - ...(params.salesman && { salesman: params.salesman }), - ...(params.dateFrom && { dateFrom: params.dateFrom }), - ...(params.dateTo && { dateTo: params.dateTo }), - ...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}), - }; - - const { data } = useSuspenseQuery(enquiriesQueryOptions(filters)); - const pageCount = Math.ceil(data.totalItems / params.perPage); - const { table } = useDataTable({ - data: data.items, - columns, - pageCount, - initialState: { - columnPinning: { right: ["actions"] }, - }, - }); - - return ( -
-
- - void setParams({ name: event.target.value || null, page: 1 }) - } - /> - - - - - void setParams({ dateFrom: event.target.value || null, page: 1 }) - } - /> - - void setParams({ dateTo: event.target.value || null, page: 1 }) - } - /> -
-
- -
- -
- ); -} diff --git a/src/features/crm/components/quotations-table.tsx b/src/features/crm/components/quotations-table.tsx deleted file mode 100644 index 99984bb..0000000 --- a/src/features/crm/components/quotations-table.tsx +++ /dev/null @@ -1,252 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useSuspenseQuery } from "@tanstack/react-query"; -import { type ColumnDef } from "@tanstack/react-table"; -import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; -import { getSortingStateParser } from "@/lib/parsers"; -import { useDataTable } from "@/hooks/use-data-table"; -import { DataTable } from "@/components/ui/table/data-table"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { HotProjectPill, QuotationStatusBadge } from "./shared"; -import { - crmReferenceQueryOptions, - quotationsQueryOptions, -} from "../api/queries"; -import type { Quotation } from "../api/types"; -import { formatCurrency } from "../utils/format"; - -const columns: ColumnDef[] = [ - { accessorKey: "code", header: "Code" }, - { accessorKey: "project", header: "Project" }, - { accessorKey: "quotationType", header: "Type" }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => , - }, - { accessorKey: "revision", header: "Rev" }, - { - accessorKey: "totalAmount", - header: "Amount", - cell: ({ row }) => formatCurrency(row.original.totalAmount), - }, - { - accessorKey: "isHotProject", - header: "Hot", - cell: ({ row }) => , - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( -
- -
- ), - }, -]; - -const columnIds = columns - .map((column) => column.id ?? String(column.accessorKey)) - .filter(Boolean); - -function KanbanView({ items }: { items: Quotation[] }) { - const statuses = [ - "draft", - "pending_approval", - "approved", - "sent", - "accepted", - "lost", - ]; - - return ( -
- {statuses.map((status) => ( - -
-

- {status.replace("_", " ")} -

- - {items.filter((item) => item.status === status).length} - -
- {items - .filter((item) => item.status === status) - .map((item) => ( - -

{item.code}

-

- {item.project} -

-

- {formatCurrency(item.totalAmount)} -

- - ))} -
- ))} -
- ); -} - -export function QuotationsTablePage() { - const [params, setParams] = useQueryStates({ - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(10), - name: parseAsString, - status: parseAsString, - quotationType: parseAsString, - salesman: parseAsString, - hot: parseAsString, - dateFrom: parseAsString, - dateTo: parseAsString, - view: parseAsString.withDefault("table"), - sort: getSortingStateParser(columnIds).withDefault([]), - }); - const { data: reference } = useSuspenseQuery(crmReferenceQueryOptions()); - - const filters = { - page: params.page, - limit: params.perPage, - ...(params.name && { search: params.name }), - ...(params.status && { status: params.status }), - ...(params.quotationType && { quotationType: params.quotationType }), - ...(params.salesman && { salesman: params.salesman }), - ...(params.hot && { hot: params.hot }), - ...(params.dateFrom && { dateFrom: params.dateFrom }), - ...(params.dateTo && { dateTo: params.dateTo }), - ...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}), - }; - - const { data } = useSuspenseQuery(quotationsQueryOptions(filters)); - const pageCount = Math.ceil(data.totalItems / params.perPage); - const { table } = useDataTable({ - data: data.items, - columns, - pageCount, - initialState: { - columnPinning: { right: ["actions"] }, - }, - }); - - return ( -
-
-
- - void setParams({ name: event.target.value || null, page: 1 }) - } - /> - - - - - - void setParams({ dateFrom: event.target.value || null, page: 1 }) - } - /> - - void setParams({ dateTo: event.target.value || null, page: 1 }) - } - /> -
- -
- - - -
-
- - {params.view === "kanban" ? ( - - ) : ( - - )} -
- ); -} diff --git a/src/features/crm/data/mock-crm.ts b/src/features/crm/data/mock-crm.ts deleted file mode 100644 index fb17190..0000000 --- a/src/features/crm/data/mock-crm.ts +++ /dev/null @@ -1,731 +0,0 @@ -import type { - ApprovalItem, - AuditEvent, - ContactShare, - ContactShareLog, - CrmBranch, - CrmSalesperson, - Customer, - CustomerContact, - DocumentSequence, - Enquiry, - MasterOption, - Quotation, - QuotationTemplate -} from '../api/types'; - -export interface CrmMockState { - branches: CrmBranch[]; - salespersons: CrmSalesperson[]; - customers: Customer[]; - contacts: CustomerContact[]; - contactShares: ContactShare[]; - contactShareLogs: ContactShareLog[]; - enquiries: Enquiry[]; - quotations: Quotation[]; - approvals: ApprovalItem[]; - auditEvents: AuditEvent[]; - sequences: DocumentSequence[]; - masterOptions: MasterOption[]; - templates: QuotationTemplate[]; -} - -export const initialCrmState: CrmMockState = { - branches: [ - { id: 'bkk', code: 'BKK', name: 'Bangkok HQ', region: 'Central' }, - { id: 'chn', code: 'CHN', name: 'Chiang Mai Branch', region: 'North' } - ], - salespersons: [ - { id: 'sp-1', name: 'Krit S.', nickname: 'Krit', branchId: 'bkk', role: 'sales' }, - { id: 'sp-2', name: 'Nicha P.', nickname: 'Nicha', branchId: 'bkk', role: 'sale_admin' }, - { id: 'sp-3', name: 'Ton A.', nickname: 'Ton', branchId: 'chn', role: 'sales' } - ], - customers: [ - { - id: 'cus-1', - code: 'CUS-BKK-001', - name: 'Siam Metro Development', - taxId: '0105556100011', - address: '88 Rama 9 Road', - province: 'Bangkok', - district: 'Huai Khwang', - subDistrict: 'Bang Kapi', - postalCode: '10310', - phone: '02-555-1001', - fax: '02-555-1999', - email: 'procurement@siammetro.co.th', - customerType: 'developer', - customerStatus: 'active', - branchId: 'bkk', - contactIds: ['ct-1', 'ct-2'] - }, - { - id: 'cus-2', - code: 'CUS-BKK-002', - name: 'Prime Lift Engineering', - taxId: '0105556100012', - address: '19 Vibhavadi Rangsit', - province: 'Bangkok', - district: 'Chatuchak', - subDistrict: 'Chom Phon', - postalCode: '10900', - phone: '02-555-2002', - email: 'sales@primelift.co.th', - customerType: 'contractor', - customerStatus: 'active', - branchId: 'bkk', - contactIds: ['ct-3', 'ct-4'] - }, - { - id: 'cus-3', - code: 'CUS-CHN-003', - name: 'Northern Cold Chain', - taxId: '0505556100013', - address: '125 Super Highway', - province: 'Chiang Mai', - district: 'Mueang', - subDistrict: 'Wat Ket', - postalCode: '50000', - phone: '053-555-3003', - email: 'project@ncc.co.th', - customerType: 'owner', - customerStatus: 'prospect', - branchId: 'chn', - contactIds: ['ct-5', 'ct-6'] - }, - { - id: 'cus-4', - code: 'CUS-BKK-004', - name: 'Urban Dock Solution', - taxId: '0105556100014', - address: '77 Bangna-Trad KM.8', - province: 'Samut Prakan', - district: 'Bang Phli', - subDistrict: 'Bang Kaeo', - postalCode: '10540', - phone: '02-555-4004', - email: 'admin@urbandock.asia', - customerType: 'consultant', - customerStatus: 'active', - branchId: 'bkk', - contactIds: ['ct-7', 'ct-8'] - }, - { - id: 'cus-5', - code: 'CUS-CHN-005', - name: 'Lanna Solar Estate', - taxId: '0505556100015', - address: '199 Ring Road', - province: 'Chiang Mai', - district: 'San Sai', - subDistrict: 'Nong Chom', - postalCode: '50210', - phone: '053-555-5005', - email: 'energy@lannasolar.co.th', - customerType: 'developer', - customerStatus: 'inactive', - branchId: 'chn', - contactIds: ['ct-9', 'ct-10'] - } - ], - contacts: [ - { id: 'ct-1', customerId: 'cus-1', name: 'Ploy Tantip', position: 'Procurement Manager', email: 'ploy@siammetro.co.th', phone: '081-111-1111', isPrimary: true }, - { id: 'ct-2', customerId: 'cus-1', name: 'Vee Chan', position: 'Project Engineer', email: 'vee@siammetro.co.th', phone: '081-111-1112' }, - { id: 'ct-3', customerId: 'cus-2', name: 'Boss K.', position: 'Managing Director', email: 'boss@primelift.co.th', phone: '082-222-2221', isPrimary: true }, - { id: 'ct-4', customerId: 'cus-2', name: 'Jane R.', position: 'Estimator', email: 'jane@primelift.co.th', phone: '082-222-2222' }, - { id: 'ct-5', customerId: 'cus-3', name: 'Aon M.', position: 'Operations Head', email: 'aon@ncc.co.th', phone: '083-333-3331', isPrimary: true }, - { id: 'ct-6', customerId: 'cus-3', name: 'Max P.', position: 'Warehouse Lead', email: 'max@ncc.co.th', phone: '083-333-3332' }, - { id: 'ct-7', customerId: 'cus-4', name: 'Mint T.', position: 'Design Consultant', email: 'mint@urbandock.asia', phone: '084-444-4441', isPrimary: true }, - { id: 'ct-8', customerId: 'cus-4', name: 'Ken D.', position: 'Project Coordinator', email: 'ken@urbandock.asia', phone: '084-444-4442' }, - { id: 'ct-9', customerId: 'cus-5', name: 'Fah N.', position: 'Energy Planning Lead', email: 'fah@lannasolar.co.th', phone: '085-555-5551', isPrimary: true }, - { id: 'ct-10', customerId: 'cus-5', name: 'Palm J.', position: 'Plant Director', email: 'palm@lannasolar.co.th', phone: '085-555-5552' } - ], - contactShares: [ - { id: 'share-1', contactId: 'ct-1', sharedWithUserId: 'u-1', sharedWithUserName: 'Nicha P.', permission: 'view', createdAt: '2026-06-01T09:00:00.000Z' }, - { id: 'share-2', contactId: 'ct-7', sharedWithUserId: 'u-2', sharedWithUserName: 'Ton A.', permission: 'edit', createdAt: '2026-06-03T10:30:00.000Z' } - ], - contactShareLogs: [ - { id: 'share-log-1', contactId: 'ct-1', action: 'SHARE', targetUserName: 'Nicha P.', actorName: 'Krit S.', createdAt: '2026-06-01T09:00:00.000Z' }, - { id: 'share-log-2', contactId: 'ct-7', action: 'SHARE', targetUserName: 'Ton A.', actorName: 'Krit S.', createdAt: '2026-06-03T10:30:00.000Z' }, - { id: 'share-log-3', contactId: 'ct-7', action: 'REVOKE', targetUserName: 'Ton A.', actorName: 'Nicha P.', createdAt: '2026-06-07T14:45:00.000Z' } - ], - enquiries: [ - { - id: 'enq-1', - code: 'ENQ2606-001', - title: 'Dock leveler replacement for Phase 3 warehouse', - customerId: 'cus-1', - contactId: 'ct-1', - branchId: 'bkk', - salesmanId: 'sp-1', - productType: 'dockdoor', - status: 'converted', - requirementSummary: 'Replace 8 dock levelers with high cycle hydraulic model.', - projectLocation: 'Bangkok Logistics Park', - chancePercent: 82, - competitor: 'Apex Dock', - expectedValue: 2750000, - dueDate: '2026-06-14', - createdAt: '2026-05-28T09:00:00.000Z', - updatedAt: '2026-06-09T15:20:00.000Z', - quotationIds: ['qt-1', 'qt-2'], - activities: [ - { id: 'enq-1-act-1', type: 'CALL', title: 'รับ requirement เบื้องต้น', detail: 'ลูกค้าต้องการเปลี่ยนภายใน Q3', actorName: 'Krit S.', createdAt: '2026-05-28T09:00:00.000Z' }, - { id: 'enq-1-act-2', type: 'VISIT', title: 'สำรวจหน้างาน', detail: 'หน้างานพร้อม shutdown 3 วัน', actorName: 'Krit S.', createdAt: '2026-05-30T13:30:00.000Z' }, - { id: 'enq-1-act-3', type: 'CONVERT', title: 'Convert to quotation', detail: 'สร้าง QT2606-001', actorName: 'Nicha P.', createdAt: '2026-06-02T08:15:00.000Z' } - ] - }, - { - id: 'enq-2', - code: 'ENQ2606-002', - title: 'Overhead crane upgrade for fabrication line', - customerId: 'cus-2', - contactId: 'ct-3', - branchId: 'bkk', - salesmanId: 'sp-1', - productType: 'crane', - status: 'requirement', - requirementSummary: '10 ton overhead crane with remote monitoring.', - projectLocation: 'Samut Sakhon Plant', - chancePercent: 68, - competitor: 'LiftPro', - expectedValue: 5200000, - dueDate: '2026-06-20', - createdAt: '2026-05-29T10:00:00.000Z', - updatedAt: '2026-06-10T11:00:00.000Z', - quotationIds: ['qt-3'], - activities: [ - { id: 'enq-2-act-1', type: 'MEETING', title: 'Kickoff meeting', detail: 'หารือ requirement ทางเทคนิค', actorName: 'Krit S.', createdAt: '2026-05-29T10:00:00.000Z' } - ] - }, - { - id: 'enq-3', - code: 'ENQ2606-003', - title: 'Solar rooftop for cold storage lot C', - customerId: 'cus-3', - contactId: 'ct-5', - branchId: 'chn', - salesmanId: 'sp-3', - productType: 'solarcell', - status: 'follow_up', - requirementSummary: '500 kWp rooftop with energy monitoring.', - projectLocation: 'Chiang Mai DC', - chancePercent: 49, - competitor: 'SunNorth', - expectedValue: 6100000, - dueDate: '2026-06-18', - createdAt: '2026-05-27T09:20:00.000Z', - updatedAt: '2026-06-08T16:10:00.000Z', - quotationIds: ['qt-4'], - activities: [ - { id: 'enq-3-act-1', type: 'NOTE', title: 'ติดตาม BOQ', detail: 'รอฝั่งลูกค้าส่งโหลดไฟฟ้า', actorName: 'Ton A.', createdAt: '2026-06-08T16:10:00.000Z' } - ] - }, - { - id: 'enq-4', - code: 'ENQ2606-004', - title: 'Dock shelter package for retrofit', - customerId: 'cus-4', - contactId: 'ct-7', - branchId: 'bkk', - salesmanId: 'sp-1', - productType: 'dockdoor', - status: 'new', - requirementSummary: 'Retrofit existing dock shelter 12 bays.', - projectLocation: 'Bangna Logistics Hub', - chancePercent: 35, - competitor: 'Apex Dock', - expectedValue: 1850000, - dueDate: '2026-06-21', - createdAt: '2026-06-05T14:00:00.000Z', - updatedAt: '2026-06-05T14:00:00.000Z', - quotationIds: [], - activities: [ - { id: 'enq-4-act-1', type: 'CALL', title: 'Lead intake', detail: 'รับ lead จาก consultant', actorName: 'Krit S.', createdAt: '2026-06-05T14:00:00.000Z' } - ] - }, - { - id: 'enq-5', - code: 'ENQ2606-005', - title: 'Crane preventive maintenance agreement', - customerId: 'cus-2', - contactId: 'ct-4', - branchId: 'bkk', - salesmanId: 'sp-1', - productType: 'crane', - status: 'qualifying', - requirementSummary: 'Annual PM contract for 6 cranes.', - projectLocation: 'Ayutthaya Plant', - chancePercent: 53, - competitor: 'LiftPro', - expectedValue: 980000, - dueDate: '2026-06-19', - createdAt: '2026-06-02T11:00:00.000Z', - updatedAt: '2026-06-06T12:00:00.000Z', - quotationIds: ['qt-5'], - activities: [ - { id: 'enq-5-act-1', type: 'UPDATE', title: 'ผ่านขั้น qualifying', detail: 'ลูกค้ามี budget แล้ว', actorName: 'Krit S.', createdAt: '2026-06-06T12:00:00.000Z' } - ] - }, - { - id: 'enq-6', - code: 'ENQ2606-006', - title: 'Solar canopy concept study', - customerId: 'cus-5', - contactId: 'ct-9', - branchId: 'chn', - salesmanId: 'sp-3', - productType: 'solarcell', - status: 'closed_lost', - requirementSummary: 'Concept study for parking canopy solar.', - projectLocation: 'Chiang Rai Service Center', - chancePercent: 0, - competitor: 'GreenBeam', - expectedValue: 2400000, - dueDate: '2026-06-08', - createdAt: '2026-05-20T09:00:00.000Z', - updatedAt: '2026-06-08T18:00:00.000Z', - quotationIds: ['qt-6'], - activities: [ - { id: 'enq-6-act-1', type: 'UPDATE', title: 'Lost to competitor', detail: 'ราคา competitor ต่ำกว่า', actorName: 'Ton A.', createdAt: '2026-06-08T18:00:00.000Z' } - ] - }, - { - id: 'enq-7', - code: 'ENQ2606-007', - title: 'High-speed door package', - customerId: 'cus-1', - contactId: 'ct-2', - branchId: 'bkk', - salesmanId: 'sp-1', - productType: 'dockdoor', - status: 'cancelled', - requirementSummary: 'High-speed doors for food-grade area.', - projectLocation: 'Pathum Thani', - chancePercent: 0, - competitor: 'FastDoor', - expectedValue: 1600000, - dueDate: '2026-06-11', - createdAt: '2026-05-25T10:10:00.000Z', - updatedAt: '2026-06-04T11:00:00.000Z', - quotationIds: [], - activities: [ - { id: 'enq-7-act-1', type: 'UPDATE', title: 'Project cancelled', detail: 'Owner เลื่อน CAPEX', actorName: 'Nicha P.', createdAt: '2026-06-04T11:00:00.000Z' } - ] - }, - { - id: 'enq-8', - code: 'ENQ2606-008', - title: 'Monorail crane for packaging line', - customerId: 'cus-3', - contactId: 'ct-6', - branchId: 'chn', - salesmanId: 'sp-3', - productType: 'crane', - status: 'new', - requirementSummary: '1 ton monorail crane with quick delivery.', - projectLocation: 'Lamphun Plant', - chancePercent: 28, - competitor: 'NorthHoist', - expectedValue: 740000, - dueDate: '2026-06-24', - createdAt: '2026-06-09T13:00:00.000Z', - updatedAt: '2026-06-09T13:00:00.000Z', - quotationIds: [], - activities: [ - { id: 'enq-8-act-1', type: 'CALL', title: 'รับ enquiry ใหม่', detail: 'ต้องการเสนอราคาใน 7 วัน', actorName: 'Ton A.', createdAt: '2026-06-09T13:00:00.000Z' } - ] - } - ], - quotations: [ - { - id: 'qt-1', - code: 'QT2606-001', - enquiryId: 'enq-1', - quotationDate: '2026-06-02', - validUntil: '2026-07-02', - quotationType: 'official', - project: 'Warehouse Dock Modernization', - siteLocation: 'Bangkok Logistics Park', - customerRoles: [ - { role: 'owner', customerId: 'cus-1', contactId: 'ct-1' }, - { role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' }, - { role: 'billing', customerId: 'cus-2', contactId: 'ct-4' } - ], - salesmanId: 'sp-1', - saleAdminId: 'sp-2', - branchId: 'bkk', - status: 'pending_approval', - revision: 0, - totalAmount: 2750000, - chancePercent: 82, - isHotProject: true, - approvalSnapshot: 'Awaiting Level 2 approval', - items: [ - { id: 'qt-1-item-1', topic: 'Mechanical', description: 'Hydraulic dock leveler 8 sets', quantity: 8, unit: 'set', unitPrice: 280000, amount: 2240000 }, - { id: 'qt-1-item-2', topic: 'Installation', description: 'Site install and commissioning', quantity: 1, unit: 'lot', unitPrice: 510000, amount: 510000 } - ], - topics: [ - { id: 'qt-1-topic-1', type: 'scope', label: 'Scope', items: ['Supply equipment', 'Install and test', 'Operator training'] }, - { id: 'qt-1-topic-2', type: 'exclusion', label: 'Exclusion', items: ['Civil work by customer', 'Night shift overtime'] }, - { id: 'qt-1-topic-3', type: 'payment', label: 'Payment', items: ['40% down payment', '50% upon delivery', '10% after handover'] } - ], - followUps: [ - { id: 'qt-1-fu-1', title: 'Prepare approval summary', dueDate: '2026-06-12', ownerName: 'Nicha P.', status: 'open' }, - { id: 'qt-1-fu-2', title: 'Confirm shutdown window', dueDate: '2026-06-13', ownerName: 'Krit S.', status: 'open' } - ], - attachments: [ - { id: 'qt-1-att-1', fileName: 'technical-spec.pdf', fileType: 'pdf', uploadedAt: '2026-06-02T10:00:00.000Z' } - ], - approvalSteps: [ - { id: 'qt-1-ap-1', level: 1, approverName: 'Head of Sales', approverPosition: 'Sales Director', status: 'approved', actedAt: '2026-06-03T09:30:00.000Z' }, - { id: 'qt-1-ap-2', level: 2, approverName: 'CEO Office', approverPosition: 'Managing Director', status: 'pending' } - ], - auditEventIds: ['audit-1', 'audit-2'] - }, - { - id: 'qt-2', - code: 'QT2606-001-R01', - enquiryId: 'enq-1', - quotationDate: '2026-06-07', - validUntil: '2026-07-07', - quotationType: 'official', - project: 'Warehouse Dock Modernization', - siteLocation: 'Bangkok Logistics Park', - customerRoles: [ - { role: 'owner', customerId: 'cus-1', contactId: 'ct-1' }, - { role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' } - ], - salesmanId: 'sp-1', - saleAdminId: 'sp-2', - branchId: 'bkk', - status: 'revised', - revision: 1, - totalAmount: 2875000, - chancePercent: 78, - isHotProject: true, - approvedPdfUrl: '/mock/qt2606-001-r01.pdf', - items: [ - { id: 'qt-2-item-1', topic: 'Mechanical', description: 'Hydraulic dock leveler 8 sets', quantity: 8, unit: 'set', unitPrice: 280000, amount: 2240000 }, - { id: 'qt-2-item-2', topic: 'Safety', description: 'Additional dock light and safety package', quantity: 1, unit: 'lot', unitPrice: 635000, amount: 635000 } - ], - topics: [ - { id: 'qt-2-topic-1', type: 'scope', label: 'Scope', items: ['Updated safety package', 'Delivery within 30 days'] }, - { id: 'qt-2-topic-2', type: 'exclusion', label: 'Exclusion', items: ['Permit by customer'] }, - { id: 'qt-2-topic-3', type: 'payment', label: 'Payment', items: ['40/50/10 milestone'] } - ], - followUps: [ - { id: 'qt-2-fu-1', title: 'Send revised file to customer', dueDate: '2026-06-12', ownerName: 'Krit S.', status: 'open' } - ], - attachments: [ - { id: 'qt-2-att-1', fileName: 'rev1-comparison.pdf', fileType: 'pdf', uploadedAt: '2026-06-07T16:00:00.000Z' } - ], - approvalSteps: [ - { id: 'qt-2-ap-1', level: 1, approverName: 'Head of Sales', approverPosition: 'Sales Director', status: 'approved', actedAt: '2026-06-07T15:30:00.000Z' }, - { id: 'qt-2-ap-2', level: 2, approverName: 'CEO Office', approverPosition: 'Managing Director', status: 'approved', actedAt: '2026-06-08T09:00:00.000Z' } - ], - auditEventIds: ['audit-3'] - }, - { - id: 'qt-3', - code: 'QT2606-002', - enquiryId: 'enq-2', - quotationDate: '2026-06-05', - validUntil: '2026-07-05', - quotationType: 'budgetary', - project: 'Fabrication Crane Upgrade', - siteLocation: 'Samut Sakhon Plant', - customerRoles: [ - { role: 'owner', customerId: 'cus-2', contactId: 'ct-3' }, - { role: 'contractor', customerId: 'cus-4', contactId: 'ct-8' } - ], - salesmanId: 'sp-1', - saleAdminId: 'sp-2', - branchId: 'bkk', - status: 'draft', - revision: 0, - totalAmount: 5200000, - chancePercent: 68, - isHotProject: false, - items: [ - { id: 'qt-3-item-1', topic: 'Crane', description: '10 ton overhead crane', quantity: 1, unit: 'set', unitPrice: 4500000, amount: 4500000 }, - { id: 'qt-3-item-2', topic: 'IoT', description: 'Remote monitoring package', quantity: 1, unit: 'set', unitPrice: 700000, amount: 700000 } - ], - topics: [ - { id: 'qt-3-topic-1', type: 'scope', label: 'Scope', items: ['Supply', 'Install', 'Commission'] }, - { id: 'qt-3-topic-2', type: 'payment', label: 'Payment', items: ['50% deposit', '40% delivery', '10% handover'] }, - { id: 'qt-3-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Building strengthening'] } - ], - followUps: [{ id: 'qt-3-fu-1', title: 'Finalize civil scope', dueDate: '2026-06-15', ownerName: 'Krit S.', status: 'open' }], - attachments: [], - approvalSteps: [], - auditEventIds: [] - }, - { - id: 'qt-4', - code: 'QT2606-003', - enquiryId: 'enq-3', - quotationDate: '2026-06-03', - validUntil: '2026-07-03', - quotationType: 'official', - project: 'Cold Chain Rooftop Solar', - siteLocation: 'Chiang Mai DC', - customerRoles: [ - { role: 'owner', customerId: 'cus-3', contactId: 'ct-5' }, - { role: 'consultant', customerId: 'cus-5', contactId: 'ct-9' } - ], - salesmanId: 'sp-3', - saleAdminId: 'sp-2', - branchId: 'chn', - status: 'sent', - revision: 0, - totalAmount: 6100000, - chancePercent: 49, - isHotProject: true, - approvedPdfUrl: '/mock/qt2606-003.pdf', - items: [ - { id: 'qt-4-item-1', topic: 'Solar', description: '500 kWp rooftop system', quantity: 1, unit: 'lot', unitPrice: 5900000, amount: 5900000 }, - { id: 'qt-4-item-2', topic: 'Monitoring', description: 'EMS dashboard', quantity: 1, unit: 'lot', unitPrice: 200000, amount: 200000 } - ], - topics: [ - { id: 'qt-4-topic-1', type: 'scope', label: 'Scope', items: ['EPC turnkey'] }, - { id: 'qt-4-topic-2', type: 'payment', label: 'Payment', items: ['30/60/10'] }, - { id: 'qt-4-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Transformer upgrade'] } - ], - followUps: [{ id: 'qt-4-fu-1', title: 'Follow up board decision', dueDate: '2026-06-11', ownerName: 'Ton A.', status: 'open' }], - attachments: [{ id: 'qt-4-att-1', fileName: 'financial-model.xls', fileType: 'xls', uploadedAt: '2026-06-03T12:00:00.000Z' }], - approvalSteps: [ - { id: 'qt-4-ap-1', level: 1, approverName: 'Regional Sales Manager', approverPosition: 'Regional Manager', status: 'approved', actedAt: '2026-06-03T11:00:00.000Z' } - ], - auditEventIds: ['audit-4'] - }, - { - id: 'qt-5', - code: 'QT2606-004', - enquiryId: 'enq-5', - quotationDate: '2026-06-06', - validUntil: '2026-07-06', - quotationType: 'service', - project: 'Annual Crane PM', - siteLocation: 'Ayutthaya Plant', - customerRoles: [{ role: 'owner', customerId: 'cus-2', contactId: 'ct-4' }], - salesmanId: 'sp-1', - saleAdminId: 'sp-2', - branchId: 'bkk', - status: 'approved', - revision: 0, - totalAmount: 980000, - chancePercent: 53, - isHotProject: false, - approvedPdfUrl: '/mock/qt2606-004.pdf', - items: [{ id: 'qt-5-item-1', topic: 'Service', description: 'PM contract 12 months', quantity: 1, unit: 'lot', unitPrice: 980000, amount: 980000 }], - topics: [ - { id: 'qt-5-topic-1', type: 'scope', label: 'Scope', items: ['Quarterly PM', 'Emergency call support'] }, - { id: 'qt-5-topic-2', type: 'payment', label: 'Payment', items: ['100% after PO'] }, - { id: 'qt-5-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Spare parts'] } - ], - followUps: [{ id: 'qt-5-fu-1', title: 'Await PO', dueDate: '2026-06-17', ownerName: 'Nicha P.', status: 'open' }], - attachments: [], - approvalSteps: [{ id: 'qt-5-ap-1', level: 1, approverName: 'Service GM', approverPosition: 'GM Service', status: 'approved', actedAt: '2026-06-07T10:00:00.000Z' }], - auditEventIds: [] - }, - { - id: 'qt-6', - code: 'QT2606-005', - enquiryId: 'enq-6', - quotationDate: '2026-05-26', - validUntil: '2026-06-25', - quotationType: 'budgetary', - project: 'Solar Canopy Feasibility', - siteLocation: 'Chiang Rai Service Center', - customerRoles: [{ role: 'owner', customerId: 'cus-5', contactId: 'ct-9' }], - salesmanId: 'sp-3', - saleAdminId: 'sp-2', - branchId: 'chn', - status: 'lost', - revision: 0, - totalAmount: 2400000, - chancePercent: 0, - isHotProject: false, - items: [{ id: 'qt-6-item-1', topic: 'Solar', description: 'Canopy concept and EPC budget', quantity: 1, unit: 'lot', unitPrice: 2400000, amount: 2400000 }], - topics: [ - { id: 'qt-6-topic-1', type: 'scope', label: 'Scope', items: ['Concept study'] }, - { id: 'qt-6-topic-2', type: 'payment', label: 'Payment', items: ['50/50'] }, - { id: 'qt-6-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Authority permit'] } - ], - followUps: [], - attachments: [], - approvalSteps: [], - auditEventIds: ['audit-5'] - }, - { - id: 'qt-7', - code: 'QT2606-006', - enquiryId: 'enq-4', - quotationDate: '2026-06-09', - validUntil: '2026-07-09', - quotationType: 'official', - project: 'Dock Shelter Retrofit', - siteLocation: 'Bangna Logistics Hub', - customerRoles: [{ role: 'consultant', customerId: 'cus-4', contactId: 'ct-7' }], - salesmanId: 'sp-1', - saleAdminId: 'sp-2', - branchId: 'bkk', - status: 'pending_approval', - revision: 0, - totalAmount: 1850000, - chancePercent: 35, - isHotProject: false, - items: [{ id: 'qt-7-item-1', topic: 'Retrofit', description: 'Dock shelter 12 bays', quantity: 12, unit: 'bay', unitPrice: 154166.67, amount: 1850000 }], - topics: [ - { id: 'qt-7-topic-1', type: 'scope', label: 'Scope', items: ['Supply and install 12 shelters'] }, - { id: 'qt-7-topic-2', type: 'payment', label: 'Payment', items: ['50/50'] }, - { id: 'qt-7-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Scaffold by others'] } - ], - followUps: [{ id: 'qt-7-fu-1', title: 'Submit approval', dueDate: '2026-06-12', ownerName: 'Nicha P.', status: 'open' }], - attachments: [], - approvalSteps: [{ id: 'qt-7-ap-1', level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'pending' }], - auditEventIds: [] - }, - { - id: 'qt-8', - code: 'QT2606-007', - enquiryId: 'enq-8', - quotationDate: '2026-06-10', - validUntil: '2026-07-10', - quotationType: 'official', - project: 'Monorail Crane Fast Track', - siteLocation: 'Lamphun Plant', - customerRoles: [{ role: 'owner', customerId: 'cus-3', contactId: 'ct-6' }], - salesmanId: 'sp-3', - saleAdminId: 'sp-2', - branchId: 'chn', - status: 'draft', - revision: 0, - totalAmount: 740000, - chancePercent: 28, - isHotProject: false, - items: [{ id: 'qt-8-item-1', topic: 'Crane', description: '1 ton monorail crane', quantity: 1, unit: 'set', unitPrice: 740000, amount: 740000 }], - topics: [ - { id: 'qt-8-topic-1', type: 'scope', label: 'Scope', items: ['Supply only'] }, - { id: 'qt-8-topic-2', type: 'payment', label: 'Payment', items: ['100% before delivery'] }, - { id: 'qt-8-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Install by customer'] } - ], - followUps: [], - attachments: [], - approvalSteps: [], - auditEventIds: [] - }, - { - id: 'qt-9', - code: 'QT2606-008', - enquiryId: 'enq-2', - quotationDate: '2026-06-08', - validUntil: '2026-07-08', - quotationType: 'official', - project: 'Crane Upgrade Option B', - siteLocation: 'Samut Sakhon Plant', - customerRoles: [{ role: 'owner', customerId: 'cus-2', contactId: 'ct-3' }], - salesmanId: 'sp-1', - saleAdminId: 'sp-2', - branchId: 'bkk', - status: 'rejected', - revision: 0, - totalAmount: 5450000, - chancePercent: 22, - isHotProject: false, - items: [{ id: 'qt-9-item-1', topic: 'Crane', description: 'Higher spec option', quantity: 1, unit: 'set', unitPrice: 5450000, amount: 5450000 }], - topics: [ - { id: 'qt-9-topic-1', type: 'scope', label: 'Scope', items: ['Higher duty cycle spec'] }, - { id: 'qt-9-topic-2', type: 'payment', label: 'Payment', items: ['40/50/10'] }, - { id: 'qt-9-topic-3', type: 'exclusion', label: 'Exclusion', items: ['Building modification'] } - ], - followUps: [], - attachments: [], - approvalSteps: [{ id: 'qt-9-ap-1', level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'rejected', actedAt: '2026-06-09T13:00:00.000Z', comment: 'Margin below target' }], - auditEventIds: [] - }, - { - id: 'qt-10', - code: 'QT2606-009', - enquiryId: 'enq-5', - quotationDate: '2026-06-10', - validUntil: '2026-07-10', - quotationType: 'service', - project: 'Spare Parts Bundle', - siteLocation: 'Ayutthaya Plant', - customerRoles: [{ role: 'billing', customerId: 'cus-2', contactId: 'ct-4' }], - salesmanId: 'sp-1', - saleAdminId: 'sp-2', - branchId: 'bkk', - status: 'accepted', - revision: 0, - totalAmount: 420000, - chancePercent: 100, - isHotProject: false, - approvedPdfUrl: '/mock/qt2606-009.pdf', - items: [{ id: 'qt-10-item-1', topic: 'Spare Parts', description: 'Critical spare bundle', quantity: 1, unit: 'lot', unitPrice: 420000, amount: 420000 }], - topics: [ - { id: 'qt-10-topic-1', type: 'scope', label: 'Scope', items: ['Spare bundle supply'] }, - { id: 'qt-10-topic-2', type: 'payment', label: 'Payment', items: ['100% with PO'] }, - { id: 'qt-10-topic-3', type: 'exclusion', label: 'Exclusion', items: ['On-site labor'] } - ], - followUps: [{ id: 'qt-10-fu-1', title: 'Coordinate delivery slot', dueDate: '2026-06-16', ownerName: 'Nicha P.', status: 'done' }], - attachments: [{ id: 'qt-10-att-1', fileName: 'spare-list.doc', fileType: 'doc', uploadedAt: '2026-06-10T09:00:00.000Z' }], - approvalSteps: [{ id: 'qt-10-ap-1', level: 1, approverName: 'Service GM', approverPosition: 'GM Service', status: 'approved', actedAt: '2026-06-10T08:00:00.000Z' }], - auditEventIds: [] - } - ], - approvals: [ - { id: 'approval-1', quotationId: 'qt-1', quotationCode: 'QT2606-001', amount: 2750000, productType: 'dockdoor', branchId: 'bkk', submitterName: 'Nicha P.', approverLevel: 2, status: 'pending' }, - { id: 'approval-2', quotationId: 'qt-7', quotationCode: 'QT2606-006', amount: 1850000, productType: 'dockdoor', branchId: 'bkk', submitterName: 'Nicha P.', approverLevel: 1, status: 'pending' }, - { id: 'approval-3', quotationId: 'qt-3', quotationCode: 'QT2606-002', amount: 5200000, productType: 'crane', branchId: 'bkk', submitterName: 'Krit S.', approverLevel: 1, status: 'pending' } - ], - auditEvents: [ - { id: 'audit-1', entityType: 'quotation', entityId: 'qt-1', action: 'CREATE', actorName: 'Nicha P.', detail: 'สร้างใบเสนอราคา QT2606-001', createdAt: '2026-06-02T08:15:00.000Z' }, - { id: 'audit-2', entityType: 'approval', entityId: 'qt-1', action: 'APPROVE', actorName: 'Head of Sales', detail: 'อนุมัติ level 1', createdAt: '2026-06-03T09:30:00.000Z' }, - { id: 'audit-3', entityType: 'quotation', entityId: 'qt-2', action: 'REVISE', actorName: 'Krit S.', detail: 'สร้าง revision R01 เพิ่ม safety package', createdAt: '2026-06-07T16:00:00.000Z' }, - { id: 'audit-4', entityType: 'quotation', entityId: 'qt-4', action: 'SEND', actorName: 'Ton A.', detail: 'ส่งใบเสนอราคาให้ลูกค้าทางอีเมล', createdAt: '2026-06-04T10:10:00.000Z' }, - { id: 'audit-5', entityType: 'quotation', entityId: 'qt-6', action: 'REJECT', actorName: 'Customer Board', detail: 'ลูกค้าเลือกคู่แข่งเนื่องจากราคา', createdAt: '2026-06-08T18:00:00.000Z' } - ], - sequences: [ - { id: 'seq-1', documentType: 'enquiry', prefix: 'ENQ', period: '2606', branchId: 'bkk', currentNumber: 8, paddingLength: 3 }, - { id: 'seq-2', documentType: 'quotation', prefix: 'QT', period: '2606', branchId: 'bkk', currentNumber: 9, paddingLength: 3 }, - { id: 'seq-3', documentType: 'quotation_revision', prefix: 'QT', period: '2606', branchId: 'bkk', currentNumber: 1, paddingLength: 2 } - ], - masterOptions: [ - { id: 'mo-1', group: 'status', code: 'new', label: 'New', active: true }, - { id: 'mo-2', group: 'status', code: 'pending_approval', label: 'Pending Approval', active: true }, - { id: 'mo-3', group: 'product_type', code: 'crane', label: 'Crane', active: true }, - { id: 'mo-4', group: 'product_type', code: 'dockdoor', label: 'Dock Door', active: true }, - { id: 'mo-5', group: 'product_type', code: 'solarcell', label: 'Solar Cell', active: true }, - { id: 'mo-6', group: 'payment_term', code: '40_50_10', label: '40/50/10', active: true }, - { id: 'mo-7', group: 'currency', code: 'THB', label: 'Thai Baht', active: true }, - { id: 'mo-8', group: 'tax_rate', code: 'VAT7', label: 'VAT 7%', active: true } - ], - templates: [ - { - id: 'tpl-1', - name: 'Standard Commercial Proposal', - version: 'v1.2', - branchId: 'bkk', - description: 'Template for standard equipment quotation.', - mappings: [ - { id: 'tpl-1-map-1', key: 'customer_name', placeholder: '{{customer_name}}', sourceField: 'customer.name' }, - { id: 'tpl-1-map-2', key: 'project_name', placeholder: '{{project_name}}', sourceField: 'quotation.project' }, - { id: 'tpl-1-map-3', key: 'quotation_price', placeholder: '{{quotation_price}}', sourceField: 'quotation.totalAmount' } - ] - }, - { - id: 'tpl-2', - name: 'Solar EPC Proposal', - version: 'v0.9', - branchId: 'chn', - description: 'Template for solar EPC quotation preview.', - mappings: [ - { id: 'tpl-2-map-1', key: 'site_location', placeholder: '{{site_location}}', sourceField: 'quotation.siteLocation' }, - { id: 'tpl-2-map-2', key: 'approver_names', placeholder: '{{approver_names}}', sourceField: 'quotation.approvalSteps[].approverName' } - ] - } - ] -}; diff --git a/src/features/foundation/components/crm-production-placeholder.tsx b/src/features/foundation/components/crm-production-placeholder.tsx new file mode 100644 index 0000000..dd849b5 --- /dev/null +++ b/src/features/foundation/components/crm-production-placeholder.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +export function CrmProductionPlaceholder({ + title, + summary, + foundationItems, + nextStep, + demoHref +}: { + title: string; + summary: string; + foundationItems: string[]; + nextStep: string; + demoHref?: string; +}) { + return ( +
+ + + {title} + {summary} + + +
+
Production path status
+
+ This route is isolated from legacy CRM mock services and is now reserved for + foundation-backed implementation only. +
+
+
+
Reusable foundation ready
+
    + {foundationItems.map((item) => ( +
  • - {item}
  • + ))} +
+
+
+
Next implementation step
+
{nextStep}
+
+ {demoHref ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/src/lib/searchparams.ts b/src/lib/searchparams.ts index 82df047..6037048 100644 --- a/src/lib/searchparams.ts +++ b/src/lib/searchparams.ts @@ -11,6 +11,15 @@ export const searchParams = { name: parseAsString, gender: parseAsString, category: parseAsString, + customerStatus: parseAsString, + branch: parseAsString, + productType: parseAsString, + salesman: parseAsString, + dateFrom: parseAsString, + dateTo: parseAsString, + quotationType: parseAsString, + hot: parseAsString, + view: parseAsString, status: parseAsString, assetType: parseAsString, role: parseAsString,