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 ( router.push('/dashboard/workspaces/team')} + type="button" + onClick={() => router.push("/dashboard/workspaces/team")} aria-label={`Open workspace ${organization.name}`} className={`w-full rounded-lg border p-4 text-left transition ${ - isActive ? 'border-primary bg-primary/5' : 'hover:bg-accent' + isActive + ? "border-primary bg-primary/5" + : "hover:bg-accent" }`} > - + - {organization.name} - - Role: {organization.role} | Plan: {organization.plan} + {organization.name} + + Role: {organization.role} - - {isActive ? 'Active' : 'Open'} + + {isActive ? "Active" : "Open"} ); }) ) : ( - - 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. - - - + + + Workspace name setWorkspaceName(event.target.value)} - placeholder='Acme Studio' + placeholder="Acme Studio" disabled={isCreating} /> - + Create workspace diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index e4ec401..568b92d 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -1,4 +1,4 @@ -import { NavGroup } from '@/types'; +import { NavGroup } from "@/types"; /** * Navigation configuration with RBAC support @@ -35,157 +35,49 @@ import { NavGroup } from '@/types'; */ export const navGroups: NavGroup[] = [ { - label: 'Overview', + label: "Overview", items: [ { - title: 'Dashboard', - url: '/dashboard', - icon: 'dashboard', + title: "Dashboard", + url: "/dashboard", + icon: "dashboard", isActive: false, - shortcut: ['d', 'd'], - items: [] + shortcut: ["d", "d"], + items: [], }, { - title: 'Workspaces', - url: '/dashboard/workspaces', - icon: 'workspace', + title: "Workspaces", + url: "/dashboard/workspaces", + icon: "workspace", isActive: false, items: [], - access: { systemRole: 'super_admin' } + access: { systemRole: "super_admin" }, }, { - title: 'Teams', - url: '/dashboard/workspaces/team', - icon: 'teams', + title: "Teams", + url: "/dashboard/workspaces/team", + icon: "teams", isActive: false, items: [], - access: { requireOrg: true, role: 'admin' } + access: { requireOrg: true, role: "admin" }, }, { - title: 'Users', - url: '/dashboard/users', - icon: 'teams', - shortcut: ['u', 'u'], + title: "Users", + url: "/dashboard/users", + icon: "teams", + shortcut: ["u", "u"], isActive: false, items: [], - access: { requireOrg: true, permission: 'users:manage' } + access: { requireOrg: true, permission: "users:manage" }, }, { - title: 'Kanban', - url: '/dashboard/kanban', - icon: 'kanban', - shortcut: ['k', 'k'], + title: "Kanban", + url: "/dashboard/kanban", + icon: "kanban", + shortcut: ["k", "k"], isActive: false, - items: [] - } - // { - // title: "Chat", - // url: "/dashboard/chat", - // icon: "chat", - // shortcut: ["c", "c"], - // isActive: false, - // items: [], - // }, - ] - } - // { - // label: "Elements", - // items: [ - // { - // title: "Forms", - // url: "#", - // icon: "forms", - // isActive: true, - // items: [ - // { - // title: "Basic Form", - // url: "/dashboard/forms/basic", - // icon: "forms", - // shortcut: ["f", "f"], - // }, - // { - // title: "Multi-Step Form", - // url: "/dashboard/forms/multi-step", - // icon: "forms", - // }, - // { - // title: "Sheet & Dialog", - // url: "/dashboard/forms/sheet-form", - // icon: "forms", - // }, - // { - // title: "Advanced Patterns", - // url: "/dashboard/forms/advanced", - // icon: "forms", - // }, - // ], - // }, - // { - // title: "React Query", - // url: "/dashboard/react-query", - // icon: "code", - // isActive: false, - // items: [], - // }, - // { - // title: "Icons", - // url: "/dashboard/elements/icons", - // icon: "palette", - // isActive: false, - // items: [], - // }, - // ], - // }, - // { - // label: "", - // items: [ - // { - // title: "Pro", - // url: "#", - // icon: "pro", - // isActive: true, - // items: [ - // { - // title: "Exclusive", - // url: "/dashboard/exclusive", - // icon: "exclusive", - // shortcut: ["e", "e"], - // }, - // ], - // }, - // { - // title: "Account", - // url: "#", - // icon: "account", - // isActive: true, - // items: [ - // { - // title: "Profile", - // url: "/dashboard/profile", - // icon: "profile", - // shortcut: ["m", "m"], - // }, - // { - // title: "Notifications", - // url: "/dashboard/notifications", - // icon: "notification", - // shortcut: ["n", "n"], - // }, - // { - // title: "Billing", - // url: "/dashboard/billing", - // icon: "billing", - // shortcut: ["b", "b"], - // access: { requireOrg: true, role: "admin" }, - // }, - // { - // title: "Login", - // shortcut: ["l", "l"], - // url: "/", - // icon: "login", - // }, - // ], - // }, - // ], - // }, + items: [], + }, + ], + }, ]; diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts new file mode 100644 index 0000000..06a2e86 --- /dev/null +++ b/src/db/seeds/foundation.seed.ts @@ -0,0 +1,298 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const postgres = require('postgres'); + +type SqlClient = ReturnType; + +type SeedOption = { + code: string; + label: string; + value: string; + sortOrder: number; +}; + +type BranchSeedRow = { + id: string; + category: string; + code: string; + label: string; +}; + +function loadEnvFile(filePath: string) { + if (!fs.existsSync(filePath)) { + return; + } + + const content = fs.readFileSync(filePath, 'utf8'); + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + + if (!line || line.startsWith('#')) { + continue; + } + + const separatorIndex = line.indexOf('='); + + if (separatorIndex === -1) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (!(key in process.env)) { + process.env[key] = value; + } + } +} + +function loadLocalEnv() { + loadEnvFile(path.resolve(process.cwd(), '.env.local')); + loadEnvFile(path.resolve(process.cwd(), '.env')); +} + +function requireEnv(name: string) { + const value = process.env[name]?.trim(); + + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + + return value; +} + +function getCurrentPeriod(date = new Date()) { + const year = String(date.getFullYear()).slice(-2); + const month = String(date.getMonth() + 1).padStart(2, '0'); + + return `${year}${month}`; +} + +const FOUNDATION_OPTIONS = { + crm_branch: [ + { code: 'head_office', label: 'สำนักงานใหญ่', value: 'head_office', sortOrder: 1 }, + { code: 'factory', label: 'Factory', value: 'factory', sortOrder: 2 }, + { code: 'service', label: 'Service', value: 'service', sortOrder: 3 } + ], + crm_customer_status: [ + { code: 'active', label: 'Active', value: 'active', sortOrder: 1 }, + { code: 'inactive', label: 'Inactive', value: 'inactive', sortOrder: 2 }, + { code: 'suspended', label: 'Suspended', value: 'suspended', sortOrder: 3 } + ], + crm_customer_type: [ + { code: 'company', label: 'Company', value: 'company', sortOrder: 1 }, + { code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 } + ], + crm_enquiry_status: [ + { code: 'new', label: 'New', value: 'new', sortOrder: 1 }, + { code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 }, + { code: 'requirement', label: 'Requirement', value: 'requirement', sortOrder: 3 }, + { code: 'follow_up', label: 'Follow Up', value: 'follow_up', sortOrder: 4 }, + { code: 'converted', label: 'Converted', value: 'converted', sortOrder: 5 }, + { code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 6 }, + { code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 7 } + ], + crm_quotation_status: [ + { code: 'draft', label: 'Draft', value: 'draft', sortOrder: 1 }, + { + code: 'pending_approval', + label: 'Pending Approval', + value: 'pending_approval', + sortOrder: 2 + }, + { code: 'approved', label: 'Approved', value: 'approved', sortOrder: 3 }, + { code: 'rejected', label: 'Rejected', value: 'rejected', sortOrder: 4 }, + { code: 'sent', label: 'Sent', value: 'sent', sortOrder: 5 }, + { code: 'accepted', label: 'Accepted', value: 'accepted', sortOrder: 6 }, + { code: 'lost', label: 'Lost', value: 'lost', sortOrder: 7 }, + { code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 8 }, + { code: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 } + ], + crm_product_type: [ + { code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 }, + { code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 }, + { code: 'solarcell', label: 'Solar Cell', value: 'solarcell', sortOrder: 3 } + ], + crm_currency: [ + { code: 'THB', label: 'THB', value: 'THB', sortOrder: 1 }, + { code: 'USD', label: 'USD', value: 'USD', sortOrder: 2 }, + { code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 } + ], + crm_payment_term: [ + { code: 'cash', label: 'Cash', value: 'cash', sortOrder: 1 }, + { code: 'credit_30', label: 'Credit 30', value: 'credit_30', sortOrder: 2 }, + { code: 'credit_60', label: 'Credit 60', value: 'credit_60', sortOrder: 3 } + ], + crm_priority: [ + { code: 'low', label: 'Low', value: 'low', sortOrder: 1 }, + { code: 'normal', label: 'Normal', value: 'normal', sortOrder: 2 }, + { code: 'high', label: 'High', value: 'high', sortOrder: 3 }, + { code: 'urgent', label: 'Urgent', value: 'urgent', sortOrder: 4 } + ], + crm_lead_channel: [ + { code: 'website', label: 'Website', value: 'website', sortOrder: 1 }, + { code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 }, + { code: 'sales', label: 'Sales', value: 'sales', sortOrder: 3 }, + { code: 'facebook', label: 'Facebook', value: 'facebook', sortOrder: 4 }, + { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }, + { code: 'other', label: 'Other', value: 'other', sortOrder: 6 } + ] +}; + +const DOCUMENT_SEQUENCES = [ + { documentType: 'customer', prefix: 'CUS' }, + { documentType: 'contact', prefix: 'CON' }, + { documentType: 'enquiry', prefix: 'ENQ' }, + { documentType: 'quotation', prefix: 'QT' }, + { documentType: 'approval', prefix: 'APV' } +]; + +async function upsertMasterOptionsForOrganization( + sql: SqlClient, + organizationId: string +): Promise { + const branchRows: BranchSeedRow[] = []; + + for (const [category, options] of Object.entries(FOUNDATION_OPTIONS) as Array< + [string, SeedOption[]] + >) { + for (const option of options) { + const [row] = await sql` + insert into ms_options ( + id, + organization_id, + category, + code, + label, + value, + parent_id, + sort_order, + is_active, + metadata, + deleted_at + ) values ( + ${crypto.randomUUID()}, + ${organizationId}, + ${category}, + ${option.code}, + ${option.label}, + ${option.value}, + ${null}, + ${option.sortOrder}, + ${true}, + ${null}, + ${null} + ) + on conflict (organization_id, category, code) do update + set + label = excluded.label, + value = excluded.value, + parent_id = excluded.parent_id, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + metadata = excluded.metadata, + deleted_at = excluded.deleted_at, + updated_at = now() + returning id, category, code, label + `; + + if (category === 'crm_branch') { + branchRows.push(row); + } + } + } + + return branchRows; +} + +async function upsertDocumentSequencesForOrganization( + sql: SqlClient, + organizationId: string, + branchRows: BranchSeedRow[] +) { + if (!branchRows.length) { + throw new Error( + `No crm_branch options were found for organization ${organizationId}. Foundation branch seed must run first.` + ); + } + + const period = getCurrentPeriod(); + + for (const branch of branchRows) { + for (const sequence of DOCUMENT_SEQUENCES) { + await sql` + insert into document_sequences ( + id, + organization_id, + branch_id, + document_type, + prefix, + period, + current_number, + padding_length, + is_active + ) values ( + ${crypto.randomUUID()}, + ${organizationId}, + ${branch.id}, + ${sequence.documentType}, + ${sequence.prefix}, + ${period}, + ${0}, + ${3}, + ${true} + ) + on conflict (organization_id, document_type, period, branch_id) do update + set + prefix = excluded.prefix, + padding_length = excluded.padding_length, + is_active = excluded.is_active, + updated_at = now() + `; + } + } +} + +async function main() { + loadLocalEnv(); + + const databaseUrl = requireEnv('DATABASE_URL'); + const sql = postgres(databaseUrl, { prepare: false }); + + try { + const organizations = await sql` + select id, name + from organizations + order by name asc + `; + + if (organizations.length === 0) { + throw new Error( + 'No organizations found. Create at least one organization before running foundation seed.' + ); + } + + for (const organization of organizations) { + const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id); + await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows); + console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); + } + } finally { + await sql.end(); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error('[seed-foundation] Failed:', error.message); + process.exitCode = 1; + }); +} diff --git a/src/features/crm/api/mutations.ts b/src/features/crm-demo/api/mutations.ts similarity index 100% rename from src/features/crm/api/mutations.ts rename to src/features/crm-demo/api/mutations.ts diff --git a/src/features/crm/api/queries.ts b/src/features/crm-demo/api/queries.ts similarity index 95% rename from src/features/crm/api/queries.ts rename to src/features/crm-demo/api/queries.ts index 2b157c2..6b7167b 100644 --- a/src/features/crm/api/queries.ts +++ b/src/features/crm-demo/api/queries.ts @@ -10,12 +10,7 @@ import { getQuotationById, getQuotations } from './service'; -import type { - ApprovalFilters, - CustomerFilters, - EnquiryFilters, - QuotationFilters -} from './types'; +import type { ApprovalFilters, CustomerFilters, EnquiryFilters, QuotationFilters } from './types'; export const crmKeys = { all: ['crm'] as const, diff --git a/src/features/crm/api/service.ts b/src/features/crm-demo/api/service.ts similarity index 75% rename from src/features/crm/api/service.ts rename to src/features/crm-demo/api/service.ts index 2ebddc6..f943d5a 100644 --- a/src/features/crm/api/service.ts +++ b/src/features/crm-demo/api/service.ts @@ -86,33 +86,109 @@ export async function getCrmDashboardData(): Promise { return clone({ metrics: [ - { id: 'm-1', label: 'Total Enquiries', value: String(crmState.enquiries.length), change: '+12%', trend: 'up' }, - { id: 'm-2', label: 'Open Enquiries', value: String(openEnquiries.length), change: '+3', trend: 'up' }, - { id: 'm-3', label: 'Pending Quotations', value: String(pendingQuotations.length), change: '+2', trend: 'up' }, - { id: 'm-4', label: 'Pending Approval', value: String(crmState.approvals.filter((item) => item.status === 'pending').length), change: 'Needs review', trend: 'flat' }, - { id: 'm-5', label: 'Won Amount', value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '+18%', trend: 'up' }, - { id: 'm-6', label: 'Lost Amount', value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '-6%', trend: 'down' }, - { id: 'm-7', label: 'Conversion Rate', value: `${conversionRate}%`, change: '+4pts', trend: 'up' }, - { id: 'm-8', label: 'Hot Projects', value: String(hotQuotations.length), change: 'Focus', trend: 'flat' }, - { id: 'm-9', label: 'Follow-up Due Today', value: String(dueFollowUps.length), change: 'Today', trend: 'flat' } + { + id: 'm-1', + label: 'Total Enquiries', + value: String(crmState.enquiries.length), + change: '+12%', + trend: 'up' + }, + { + id: 'm-2', + label: 'Open Enquiries', + value: String(openEnquiries.length), + change: '+3', + trend: 'up' + }, + { + id: 'm-3', + label: 'Pending Quotations', + value: String(pendingQuotations.length), + change: '+2', + trend: 'up' + }, + { + id: 'm-4', + label: 'Pending Approval', + value: String(crmState.approvals.filter((item) => item.status === 'pending').length), + change: 'Needs review', + trend: 'flat' + }, + { + id: 'm-5', + label: 'Won Amount', + value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, + change: '+18%', + trend: 'up' + }, + { + id: 'm-6', + label: 'Lost Amount', + value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, + change: '-6%', + trend: 'down' + }, + { + id: 'm-7', + label: 'Conversion Rate', + value: `${conversionRate}%`, + change: '+4pts', + trend: 'up' + }, + { + id: 'm-8', + label: 'Hot Projects', + value: String(hotQuotations.length), + change: 'Focus', + trend: 'flat' + }, + { + id: 'm-9', + label: 'Follow-up Due Today', + value: String(dueFollowUps.length), + change: 'Today', + trend: 'flat' + } ], enquiryPipeline: [ { label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length }, - { label: 'Qualifying', value: crmState.enquiries.filter((item) => item.status === 'qualifying').length }, - { label: 'Requirement', value: crmState.enquiries.filter((item) => item.status === 'requirement').length }, - { label: 'Follow Up', value: crmState.enquiries.filter((item) => item.status === 'follow_up').length }, - { label: 'Converted', value: crmState.enquiries.filter((item) => item.status === 'converted').length } + { + label: 'Qualifying', + value: crmState.enquiries.filter((item) => item.status === 'qualifying').length + }, + { + label: 'Requirement', + value: crmState.enquiries.filter((item) => item.status === 'requirement').length + }, + { + label: 'Follow Up', + value: crmState.enquiries.filter((item) => item.status === 'follow_up').length + }, + { + label: 'Converted', + value: crmState.enquiries.filter((item) => item.status === 'converted').length + } ], quotationPipeline: [ - { label: 'Draft', value: crmState.quotations.filter((item) => item.status === 'draft').length }, - { label: 'Pending', value: crmState.quotations.filter((item) => item.status === 'pending_approval').length }, + { + label: 'Draft', + value: crmState.quotations.filter((item) => item.status === 'draft').length + }, + { + label: 'Pending', + value: crmState.quotations.filter((item) => item.status === 'pending_approval').length + }, { label: 'Sent', value: crmState.quotations.filter((item) => item.status === 'sent').length }, - { label: 'Accepted', value: crmState.quotations.filter((item) => item.status === 'accepted').length }, + { + label: 'Accepted', + value: crmState.quotations.filter((item) => item.status === 'accepted').length + }, { label: 'Lost', value: crmState.quotations.filter((item) => item.status === 'lost').length } ], salesPerformance: crmState.salespersons.map((salesperson) => ({ label: salesperson.nickname, - value: crmState.quotations.filter((quotation) => quotation.salesmanId === salesperson.id).length, + value: crmState.quotations.filter((quotation) => quotation.salesmanId === salesperson.id) + .length, secondaryValue: crmState.quotations .filter((quotation) => quotation.salesmanId === salesperson.id) .reduce((sum, quotation) => sum + quotation.totalAmount, 0) @@ -170,7 +246,9 @@ export async function getCustomerById(id: string): Promise contact.customerId === id), shares: crmState.contactShares.filter((share) => - crmState.contacts.some((contact) => contact.id === share.contactId && contact.customerId === id) + crmState.contacts.some( + (contact) => contact.id === share.contactId && contact.customerId === id + ) ), shareLogs: crmState.contactShareLogs.filter((log) => crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id) @@ -190,14 +268,16 @@ export async function getEnquiries(filters: EnquiryFilters): Promise item.code.toLowerCase().includes(search) || item.title.toLowerCase().includes(search) + (item) => + item.code.toLowerCase().includes(search) || item.title.toLowerCase().includes(search) ); } if (filters.status) items = items.filter((item) => item.status === filters.status); if (filters.productType) items = items.filter((item) => item.productType === filters.productType); if (filters.salesman) items = items.filter((item) => item.salesmanId === filters.salesman); if (filters.dateFrom) items = items.filter((item) => item.createdAt >= filters.dateFrom!); - if (filters.dateTo) items = items.filter((item) => item.createdAt <= `${filters.dateTo!}T23:59:59.999Z`); + if (filters.dateTo) + items = items.filter((item) => item.createdAt <= `${filters.dateTo!}T23:59:59.999Z`); items = sortItems(items, filters.sort); return clone(paginate(items, filters.page, filters.limit)); @@ -235,11 +315,18 @@ export async function getQuotations( ); } if (filters.status) items = items.filter((quotation) => quotation.status === filters.status); - if (filters.quotationType) items = items.filter((quotation) => quotation.quotationType === filters.quotationType); - if (filters.salesman) items = items.filter((quotation) => quotation.salesmanId === filters.salesman); - if (filters.hot) items = items.filter((quotation) => (filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject)); - if (filters.dateFrom) items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!); - if (filters.dateTo) items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!); + if (filters.quotationType) + items = items.filter((quotation) => quotation.quotationType === filters.quotationType); + if (filters.salesman) + items = items.filter((quotation) => quotation.salesmanId === filters.salesman); + if (filters.hot) + items = items.filter((quotation) => + filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject + ); + if (filters.dateFrom) + items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!); + if (filters.dateTo) + items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!); items = sortItems(items, filters.sort); return clone(paginate(items, filters.page, filters.limit)); @@ -253,7 +340,9 @@ export async function getQuotationById(id: string): Promise item.id === quotation.enquiryId)!; const customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))]; - const contactIds = [...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))] as string[]; + const contactIds = [ + ...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean)) + ] as string[]; return clone({ quotation, @@ -301,7 +390,9 @@ export async function convertEnquiryToQuotation(enquiryId: string) { quotationType: 'official', project: enquiry.title, siteLocation: enquiry.projectLocation, - customerRoles: [{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }], + customerRoles: [ + { role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId } + ], salesmanId: enquiry.salesmanId, saleAdminId: 'sp-2', branchId: enquiry.branchId, @@ -310,10 +401,30 @@ export async function convertEnquiryToQuotation(enquiryId: string) { totalAmount: enquiry.expectedValue, chancePercent: enquiry.chancePercent, isHotProject: enquiry.chancePercent >= 70, - items: [{ id: `${nextId}-item-1`, topic: 'Scope', description: enquiry.requirementSummary, quantity: 1, unit: 'lot', unitPrice: enquiry.expectedValue, amount: enquiry.expectedValue }], + items: [ + { + id: `${nextId}-item-1`, + topic: 'Scope', + description: enquiry.requirementSummary, + quantity: 1, + unit: 'lot', + unitPrice: enquiry.expectedValue, + amount: enquiry.expectedValue + } + ], topics: [ - { id: `${nextId}-topic-1`, type: 'scope', label: 'Scope', items: [enquiry.requirementSummary] }, - { id: `${nextId}-topic-2`, type: 'exclusion', label: 'Exclusion', items: ['To be confirmed'] }, + { + id: `${nextId}-topic-1`, + type: 'scope', + label: 'Scope', + items: [enquiry.requirementSummary] + }, + { + id: `${nextId}-topic-2`, + type: 'exclusion', + label: 'Exclusion', + items: ['To be confirmed'] + }, { id: `${nextId}-topic-3`, type: 'payment', label: 'Payment', items: ['Pending setup'] } ], followUps: [], @@ -357,7 +468,13 @@ export async function submitQuotationApproval(quotationId: string) { quotation.approvalSteps.length > 0 ? quotation.approvalSteps : [ - { id: `${quotationId}-ap-1`, level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'pending' } + { + id: `${quotationId}-ap-1`, + level: 1, + approverName: 'Sales Director', + approverPosition: 'Director', + status: 'pending' + } ] } : quotation @@ -368,11 +485,11 @@ export async function approveQuotation(payload: ApprovalActionPayload) { crmState.quotations = crmState.quotations.map((quotation) => { if (quotation.id !== payload.quotationId) return quotation; - const nextSteps = quotation.approvalSteps.map((step, index) => + const nextSteps: typeof quotation.approvalSteps = quotation.approvalSteps.map((step, index) => index === quotation.approvalSteps.findIndex((item) => item.status === 'pending') ? { ...step, - status: 'approved', + status: 'approved' as const, actedAt: '2026-06-11T11:00:00.000Z', comment: payload.comment } @@ -384,7 +501,9 @@ export async function approveQuotation(payload: ApprovalActionPayload) { ...quotation, approvalSteps: nextSteps, status: hasPending ? 'pending_approval' : 'approved', - approvedPdfUrl: hasPending ? quotation.approvedPdfUrl : `/mock/${quotation.code.toLowerCase()}.pdf` + approvedPdfUrl: hasPending + ? quotation.approvedPdfUrl + : `/mock/${quotation.code.toLowerCase()}.pdf` }; }); @@ -398,12 +517,12 @@ export async function rejectQuotation(payload: ApprovalActionPayload) { quotation.id === payload.quotationId ? { ...quotation, - status: 'rejected', + status: 'rejected' as const, approvalSteps: quotation.approvalSteps.map((step, index) => index === quotation.approvalSteps.findIndex((item) => item.status === 'pending') ? { ...step, - status: 'rejected', + status: 'rejected' as const, actedAt: '2026-06-11T11:30:00.000Z', comment: payload.comment } diff --git a/src/features/crm/api/types.ts b/src/features/crm-demo/api/types.ts similarity index 100% rename from src/features/crm/api/types.ts rename to src/features/crm-demo/api/types.ts diff --git a/src/features/crm/components/approvals-page.tsx b/src/features/crm-demo/components/approvals-page.tsx similarity index 97% rename from src/features/crm/components/approvals-page.tsx rename to src/features/crm-demo/components/approvals-page.tsx index 5ef351e..ef7cdee 100644 --- a/src/features/crm/components/approvals-page.tsx +++ b/src/features/crm-demo/components/approvals-page.tsx @@ -44,7 +44,11 @@ function DecisionDialog({ {label} {description} - setComment(event.target.value)} placeholder='Comment' /> + setComment(event.target.value)} + placeholder='Comment' + /> onConfirm(comment)}> Confirm diff --git a/src/features/crm/components/crm-dashboard.tsx b/src/features/crm-demo/components/crm-dashboard.tsx similarity index 80% rename from src/features/crm/components/crm-dashboard.tsx rename to src/features/crm-demo/components/crm-dashboard.tsx index 243d536..670acc3 100644 --- a/src/features/crm/components/crm-dashboard.tsx +++ b/src/features/crm-demo/components/crm-dashboard.tsx @@ -1,7 +1,18 @@ 'use client'; import { useSuspenseQuery } from '@tanstack/react-query'; -import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Pie, + PieChart, + XAxis, + YAxis +} from 'recharts'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; import { crmDashboardQueryOptions } from '../api/queries'; @@ -58,11 +69,25 @@ export function CrmDashboardPage() { - + {data.quotationPipeline.map((entry, index) => ( ))} @@ -127,8 +152,20 @@ export function CrmDashboardPage() { } /> - - + + @@ -136,7 +173,11 @@ export function CrmDashboardPage() { - + {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 }) => ( + + { + setEditingCustomer(row.original); + setSheetOpen(true); + }} + > + Edit + + + View + + + ) + } + ], + [] + ); + + 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 })} + /> + + void setParams({ + customerStatus: event.target.value || null, + page: 1 + }) + } + > + ทุกสถานะ + Active + Prospect + Inactive + + void setParams({ branch: event.target.value || null, page: 1 })} + > + ทุกสาขา + {reference.branches.map((branch) => ( + + {branch.name} + + ))} + + + { + setEditingCustomer(undefined); + setSheetOpen(true); + }} + > + + Create Customer + + + + + { + 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 ( + + + View + + mutation.mutate(enquiry.id)}> + Convert + + + ); +} + +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({ status: event.target.value || null, page: 1 })} + > + ทุกสถานะ + New + Qualifying + Requirement + Follow Up + Converted + + void setParams({ productType: event.target.value || null, page: 1 })} + > + ทุกสินค้า + Crane + Dock Door + Solar Cell + + void setParams({ salesman: event.target.value || null, page: 1 })} + > + ทุก salesperson + {reference.salespersons.map((sales) => ( + + {sales.name} + + ))} + + void setParams({ dateFrom: event.target.value || null, page: 1 })} + /> + void setParams({ dateTo: event.target.value || null, page: 1 })} + /> + + + + Create Enquiry + + + + + ); +} 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 }) => ( + + + View + + + ) + } +]; + +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({ status: event.target.value || null, page: 1 })} + > + ทุกสถานะ + Draft + Pending Approval + Approved + Sent + Accepted + + + void setParams({ + quotationType: event.target.value || null, + page: 1 + }) + } + > + ทุกประเภท + Official + Budgetary + Service + + void setParams({ salesman: event.target.value || null, page: 1 })} + > + ทุก salesperson + {reference.salespersons.map((sales) => ( + + {sales.name} + + ))} + + void setParams({ hot: event.target.value || null, page: 1 })} + > + ทุกดีล + Hot only + Non-hot + + void setParams({ dateFrom: event.target.value || null, page: 1 })} + /> + void setParams({ dateTo: event.target.value || null, page: 1 })} + /> + + + + + Create quotation + + void setParams({ view: 'table' })} + > + Table + + void setParams({ view: 'kanban' })} + > + Kanban + + + + + {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)} - + เปิด @@ -347,11 +379,7 @@ export function AuditTimeline({ events }: { events: AuditEvent[] }) { actorName: event.actorName, timestamp: event.createdAt, tone: - event.action === 'APPROVE' - ? 'success' - : event.action === 'REJECT' - ? 'danger' - : 'default' + event.action === 'APPROVE' ? 'success' : event.action === 'REJECT' ? 'danger' : 'default' }))} /> ); diff --git a/src/features/crm-demo/data/mock-crm.ts b/src/features/crm-demo/data/mock-crm.ts new file mode 100644 index 0000000..b2b3960 --- /dev/null +++ b/src/features/crm-demo/data/mock-crm.ts @@ -0,0 +1,1377 @@ +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/crm/data/options.ts b/src/features/crm-demo/data/options.ts similarity index 100% rename from src/features/crm/data/options.ts rename to src/features/crm-demo/data/options.ts diff --git a/src/features/crm/schemas/customer.ts b/src/features/crm-demo/schemas/customer.ts similarity index 100% rename from src/features/crm/schemas/customer.ts rename to src/features/crm-demo/schemas/customer.ts diff --git a/src/features/crm/utils/format.ts b/src/features/crm-demo/utils/format.ts similarity index 100% rename from src/features/crm/utils/format.ts rename to src/features/crm-demo/utils/format.ts diff --git a/src/features/crm/utils/status.ts b/src/features/crm-demo/utils/status.ts similarity index 100% rename from src/features/crm/utils/status.ts rename to src/features/crm-demo/utils/status.ts diff --git a/src/features/crm/components/customers-table.tsx b/src/features/crm/components/customers-table.tsx deleted file mode 100644 index 17307df..0000000 --- a/src/features/crm/components/customers-table.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"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< - Customer | undefined - >(); - 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 }) => ( - - { - setEditingCustomer(row.original); - setSheetOpen(true); - }} - > - Edit - - - - View - - - - ), - }, - ], - [], - ); - - 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 }) - } - /> - - void setParams({ - customerStatus: event.target.value || null, - page: 1, - }) - } - > - ทุกสถานะ - Active - Prospect - Inactive - - - void setParams({ branch: event.target.value || null, page: 1 }) - } - > - ทุกสาขา - {reference.branches.map((branch) => ( - - {branch.name} - - ))} - - - { - setEditingCustomer(undefined); - setSheetOpen(true); - }} - > - - Create Customer - - - - - { - 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 ( - - - View - - mutation.mutate(enquiry.id)} - > - Convert - - - ); -} - -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({ status: event.target.value || null, page: 1 }) - } - > - ทุกสถานะ - New - Qualifying - Requirement - Follow Up - Converted - - - void setParams({ productType: event.target.value || null, page: 1 }) - } - > - ทุกสินค้า - Crane - Dock Door - Solar Cell - - - void setParams({ salesman: event.target.value || null, page: 1 }) - } - > - ทุก salesperson - {reference.salespersons.map((sales) => ( - - {sales.name} - - ))} - - - void setParams({ dateFrom: event.target.value || null, page: 1 }) - } - /> - - void setParams({ dateTo: event.target.value || null, page: 1 }) - } - /> - - - - Create Enquiry - - - - - ); -} 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 }) => ( - - - - View - - - - ), - }, -]; - -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({ status: event.target.value || null, page: 1 }) - } - > - ทุกสถานะ - Draft - Pending Approval - Approved - Sent - Accepted - - - void setParams({ - quotationType: event.target.value || null, - page: 1, - }) - } - > - ทุกประเภท - Official - Budgetary - Service - - - void setParams({ salesman: event.target.value || null, page: 1 }) - } - > - ทุก salesperson - {reference.salespersons.map((sales) => ( - - {sales.name} - - ))} - - - void setParams({ hot: event.target.value || null, page: 1 }) - } - > - ทุกดีล - Hot only - Non-hot - - - void setParams({ dateFrom: event.target.value || null, page: 1 }) - } - /> - - void setParams({ dateTo: event.target.value || null, page: 1 }) - } - /> - - - - - Create quotation - - void setParams({ view: "table" })} - > - Table - - void setParams({ view: "kanban" })} - > - Kanban - - - - - {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 ? ( + + + Open legacy demo route + + + ) : 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,
{item.quotationCode}
{item.productType} / Approver Level {item.approverLevel}
+ {item.productType} / Approver Level {item.approverLevel} +
{formatCurrency(item.amount)}
{row.original.name}
{row.original.email}
{status.replace('_', ' ')}
{item.code}
{item.project}
{formatCurrency(item.totalAmount)}
- {item.prefix}{item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')} + {item.prefix} + {item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')}
{item.documentType}
{item.label}
{role.role}
{quotation.project}
quotation_date: {quotation.quotationDate}
valid_until: {quotation.validUntil}
currency: THB
+ quotation_date:{' '} + {quotation.quotationDate} +
+ valid_until: {quotation.validUntil} +
+ currency: THB +
item_topic
{item.description}
@@ -279,7 +306,9 @@ export function QuotationPreviewPanel({
{quotation.code}
{formatCurrency(quotation.totalAmount)}
- {row.original.email} -
- {status.replace("_", " ")} -
- {item.project} -
- {formatCurrency(item.totalAmount)} -