task-b
task-b.1 complere
This commit is contained in:
8
docs/adr/ADR-0006-branch-domain-model.md
Normal file
8
docs/adr/ADR-0006-branch-domain-model.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Current:
|
||||||
|
branch stored in ms_options
|
||||||
|
|
||||||
|
Future:
|
||||||
|
organization_branches table
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
branch will contain business metadata and workflow configuration
|
||||||
337
docs/implementation/task-b-template-audit.md
Normal file
337
docs/implementation/task-b-template-audit.md
Normal file
@@ -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
|
||||||
279
docs/implementation/task-b1-foundation-stabilization.md
Normal file
279
docs/implementation/task-b1-foundation-stabilization.md
Normal file
@@ -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
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"migrate": "npx drizzle-kit migrate",
|
"migrate": "npx drizzle-kit migrate",
|
||||||
"studio": "npx drizzle-kit studio",
|
"studio": "npx drizzle-kit studio",
|
||||||
"seed:super-admin": "node scripts/seed-super-admin.js",
|
"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": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"@tanstack/react-query": "^5.95.2",
|
"@tanstack/react-query": "^5.95.2",
|
||||||
"@tanstack/react-query-devtools": "^5.95.2",
|
"@tanstack/react-query-devtools": "^5.95.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -92,8 +94,7 @@
|
|||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.12",
|
"zustand": "^5.0.12"
|
||||||
"bcryptjs": "^3.0.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^9.9.0",
|
"@faker-js/faker": "^9.9.0",
|
||||||
|
|||||||
468
plans/task-b.1.md
Normal file
468
plans/task-b.1.md
Normal file
@@ -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
|
||||||
26
src/app/dashboard/crm-demo/approvals/page.tsx
Normal file
26
src/app/dashboard/crm-demo/approvals/page.tsx
Normal file
@@ -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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Approvals'
|
||||||
|
pageDescription='Pending approval list, approver level, branch, amount และ approve/reject dialog'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<ApprovalsPage />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/app/dashboard/crm-demo/customers/[id]/page.tsx
Normal file
24
src/app/dashboard/crm-demo/customers/[id]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Customer Detail'
|
||||||
|
pageDescription='Overview, contacts, shared contacts, enquiries, quotations, and activity log'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<CustomerDetailPage id={id} />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/dashboard/crm-demo/customers/page.tsx
Normal file
49
src/app/dashboard/crm-demo/customers/page.tsx
Normal file
@@ -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<SearchParams>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Customers'
|
||||||
|
pageDescription='Customer master พร้อม status, branch, contacts และ drawer สำหรับ create/edit'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<CustomersTablePage />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/app/dashboard/crm-demo/enquiries/[id]/page.tsx
Normal file
24
src/app/dashboard/crm-demo/enquiries/[id]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Enquiry Detail'
|
||||||
|
pageDescription='Header summary, customer/contact, requirement summary, timeline, and related quotations'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<EnquiryDetailPage id={id} />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/app/dashboard/crm-demo/enquiries/page.tsx
Normal file
55
src/app/dashboard/crm-demo/enquiries/page.tsx
Normal file
@@ -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<SearchParams>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Enquiries'
|
||||||
|
pageDescription='List ของ sales opportunity พร้อม filter, date range และ convert to quotation'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<EnquiriesTablePage />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/dashboard/crm-demo/page.tsx
Normal file
25
src/app/dashboard/crm-demo/page.tsx
Normal file
@@ -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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='CRM Dashboard'
|
||||||
|
pageDescription='ภาพรวม workflow ของ Customer → Enquiry → Quotation → Approval → Won/Lost'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<CrmDashboardPage />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/app/dashboard/crm-demo/quotations/[id]/page.tsx
Normal file
24
src/app/dashboard/crm-demo/quotations/[id]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Quotation Detail'
|
||||||
|
pageDescription='Header, status, revision, customer roles, item table, approval timeline, and preview panel'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<QuotationDetailPage id={id} />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/app/dashboard/crm-demo/quotations/page.tsx
Normal file
57
src/app/dashboard/crm-demo/quotations/page.tsx
Normal file
@@ -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<SearchParams>;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Quotations'
|
||||||
|
pageDescription='Quotation table with filters, hot project indicator, and kanban toggle'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<QuotationsTablePage />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Document Sequences'
|
||||||
|
pageDescription='Mock document sequence แยกตาม branch และ document type'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<DocumentSequencesPage />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/app/dashboard/crm-demo/settings/master-options/page.tsx
Normal file
37
src/app/dashboard/crm-demo/settings/master-options/page.tsx
Normal file
@@ -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<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Master Options'
|
||||||
|
pageDescription='Organization-scoped CRM option registry for statuses, product types, payment terms, currency, and branch abstractions.'
|
||||||
|
access={canManageOptions}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to master option management.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MasterOptionsListingPage />
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/app/dashboard/crm-demo/settings/templates/page.tsx
Normal file
21
src/app/dashboard/crm-demo/settings/templates/page.tsx
Normal file
@@ -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 (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Quotation Templates'
|
||||||
|
pageDescription='Mock quotation template, version และ placeholder mappings สำหรับต่อยอด PDF preview'
|
||||||
|
>
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<TemplatesPage />
|
||||||
|
</HydrationBoundary>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { approvalsQueryOptions, crmReferenceQueryOptions } from '@/features/crm/api/queries';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { ApprovalsPage } from '@/features/crm/components/approvals-page';
|
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM Approvals'
|
title: 'Dashboard: CRM Approvals'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ApprovalsRoute() {
|
export default function ApprovalsRoute() {
|
||||||
const queryClient = getQueryClient();
|
|
||||||
void queryClient.prefetchQuery(crmReferenceQueryOptions());
|
|
||||||
void queryClient.prefetchQuery(approvalsQueryOptions({ page: 1, limit: 10 }));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Approvals'
|
pageTitle='Approvals'
|
||||||
pageDescription='Pending approval list, approver level, branch, amount และ approve/reject dialog'
|
pageDescription='Production approval workflow has not started yet. This route no longer imports demo approval state.'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<ApprovalsPage />
|
title='Approval workflow pending'
|
||||||
</HydrationBoundary>
|
summary='The old pending approvals mock list now lives only under crm-demo.'
|
||||||
|
foundationItems={[
|
||||||
|
'Permission checks',
|
||||||
|
'Business-role-aware access helpers',
|
||||||
|
'Branch validation',
|
||||||
|
'Audit log helper'
|
||||||
|
]}
|
||||||
|
nextStep='Implement production approval workflow only after quotation persistence and routing are in place.'
|
||||||
|
demoHref='/dashboard/crm-demo/approvals'
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { customerByIdOptions } from '@/features/crm/api/queries';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { CustomerDetailPage } from '@/features/crm/components/customer-detail';
|
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
type PageProps = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
export default async function CustomerDetailRoute({ params }: PageProps) {
|
export default async function CustomerDetailRoute({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const queryClient = getQueryClient();
|
|
||||||
void queryClient.prefetchQuery(customerByIdOptions(id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Customer Detail'
|
pageTitle='Customer Detail'
|
||||||
pageDescription='Overview, contacts, shared contacts, enquiries, quotations, and activity log'
|
pageDescription={`Production detail route placeholder for customer ${id}.`}
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<CustomerDetailPage id={id} />
|
title='Customer detail pending'
|
||||||
</HydrationBoundary>
|
summary='This detail route is intentionally isolated from legacy demo state until the production Customer module is implemented.'
|
||||||
|
foundationItems={[
|
||||||
|
'Current organization context',
|
||||||
|
'Permission checks',
|
||||||
|
'Branch validation abstraction',
|
||||||
|
'Audit log helper'
|
||||||
|
]}
|
||||||
|
nextStep='Implement customer detail using real customer, contact, enquiry, and audit data from production APIs.'
|
||||||
|
demoHref={`/dashboard/crm-demo/customers/${id}`}
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 PageContainer from '@/components/layout/page-container';
|
||||||
import { crmReferenceQueryOptions, customersQueryOptions } from '@/features/crm/api/queries';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { CustomersTablePage } from '@/features/crm/components/customers-table';
|
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
|
||||||
import { searchParamsCache } from '@/lib/searchparams';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM Customers'
|
title: 'Dashboard: CRM Customers'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function CustomersRoute({
|
export default function CustomersRoute() {
|
||||||
searchParams
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<SearchParams>;
|
|
||||||
}) {
|
|
||||||
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 (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Customers'
|
pageTitle='Customers'
|
||||||
pageDescription='Customer master พร้อม status, branch, contacts และ drawer สำหรับ create/edit'
|
pageDescription='Production customer CRUD has not started yet. This route is reserved for the Task C implementation path.'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<CustomersTablePage />
|
title='Customer module pending'
|
||||||
</HydrationBoundary>
|
summary='The old customer list and form flow has been moved to the demo path so this route no longer depends on mock services.'
|
||||||
|
foundationItems={[
|
||||||
|
'Organization-first access boundary',
|
||||||
|
'Customer status options from master options',
|
||||||
|
'Branch scope abstraction for customer ownership',
|
||||||
|
'Document code generation for customer records',
|
||||||
|
'Audit hooks for future mutations'
|
||||||
|
]}
|
||||||
|
nextStep='Implement Customer list, detail, and create/edit flows with real route handlers and persistence.'
|
||||||
|
demoHref='/dashboard/crm-demo/customers'
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { enquiryByIdOptions } from '@/features/crm/api/queries';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { EnquiryDetailPage } from '@/features/crm/components/enquiry-detail';
|
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
type PageProps = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
export default async function EnquiryDetailRoute({ params }: PageProps) {
|
export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const queryClient = getQueryClient();
|
|
||||||
void queryClient.prefetchQuery(enquiryByIdOptions(id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Enquiry Detail'
|
pageTitle='Enquiry Detail'
|
||||||
pageDescription='Header summary, customer/contact, requirement summary, timeline, and related quotations'
|
pageDescription={`Production detail route placeholder for enquiry ${id}.`}
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<EnquiryDetailPage id={id} />
|
title='Enquiry detail pending'
|
||||||
</HydrationBoundary>
|
summary='This route is reserved for the production enquiry detail page and no longer imports the demo in-memory service.'
|
||||||
|
foundationItems={[
|
||||||
|
'Organization context',
|
||||||
|
'Branch validation',
|
||||||
|
'Master option lookups',
|
||||||
|
'Audit logging'
|
||||||
|
]}
|
||||||
|
nextStep='Implement enquiry detail on top of persisted enquiry, activity, and relationship data.'
|
||||||
|
demoHref={`/dashboard/crm-demo/enquiries/${id}`}
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 PageContainer from '@/components/layout/page-container';
|
||||||
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm/api/queries';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { EnquiriesTablePage } from '@/features/crm/components/enquiries-table';
|
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
|
||||||
import { searchParamsCache } from '@/lib/searchparams';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM Enquiries'
|
title: 'Dashboard: CRM Enquiries'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function EnquiriesRoute({
|
export default function EnquiriesRoute() {
|
||||||
searchParams
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<SearchParams>;
|
|
||||||
}) {
|
|
||||||
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 (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Enquiries'
|
pageTitle='Enquiries'
|
||||||
pageDescription='List ของ sales opportunity พร้อม filter, date range และ convert to quotation'
|
pageDescription='Production enquiry flows are intentionally blocked from mock services until the real module is built.'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<EnquiriesTablePage />
|
title='Enquiry module pending'
|
||||||
</HydrationBoundary>
|
summary='The demo enquiry workflow remains available only under crm-demo while this production route stays foundation-safe.'
|
||||||
|
foundationItems={[
|
||||||
|
'Master option statuses',
|
||||||
|
'Organization-scoped document sequences',
|
||||||
|
'Branch scope abstraction',
|
||||||
|
'Permission layer',
|
||||||
|
'Audit logging'
|
||||||
|
]}
|
||||||
|
nextStep='Implement enquiry listing and detail flows on production APIs after Customer and Contact are in place.'
|
||||||
|
demoHref='/dashboard/crm-demo/enquiries'
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { crmDashboardQueryOptions } from '@/features/crm/api/queries';
|
|
||||||
import { CrmDashboardPage } from '@/features/crm/components/crm-dashboard';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM'
|
title: 'Dashboard: CRM'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CrmDashboardRoute() {
|
export default function CrmDashboardRoute() {
|
||||||
const queryClient = getQueryClient();
|
|
||||||
void queryClient.prefetchQuery(crmDashboardQueryOptions());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='CRM Dashboard'
|
pageTitle='CRM Foundation'
|
||||||
pageDescription='ภาพรวม workflow ของ Customer → Enquiry → Quotation → Approval → Won/Lost'
|
pageDescription='Production CRM routes are now isolated from legacy mock state and ready for foundation-backed modules.'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<CrmDashboardPage />
|
title='CRM production workspace'
|
||||||
</HydrationBoundary>
|
summary='Task B.1 isolates the production CRM path so Task C can start on real architecture instead of in-memory demo state.'
|
||||||
|
foundationItems={[
|
||||||
|
'Current user context',
|
||||||
|
'Organization context',
|
||||||
|
'Permission helpers',
|
||||||
|
'Branch scope abstraction',
|
||||||
|
'Master options service',
|
||||||
|
'Document sequence service',
|
||||||
|
'Audit log helper'
|
||||||
|
]}
|
||||||
|
nextStep='Start Customer and Contact modules on top of Route Handlers, Drizzle, and organization-first access checks.'
|
||||||
|
demoHref='/dashboard/crm-demo'
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { quotationByIdOptions } from '@/features/crm/api/queries';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { QuotationDetailPage } from '@/features/crm/components/quotation-detail';
|
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
type PageProps = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
export default async function QuotationDetailRoute({ params }: PageProps) {
|
export default async function QuotationDetailRoute({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const queryClient = getQueryClient();
|
|
||||||
void queryClient.prefetchQuery(quotationByIdOptions(id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Quotation Detail'
|
pageTitle='Quotation Detail'
|
||||||
pageDescription='Header, status, revision, customer roles, item table, approval timeline, and preview panel'
|
pageDescription={`Production detail route placeholder for quotation ${id}.`}
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<QuotationDetailPage id={id} />
|
title='Quotation detail pending'
|
||||||
</HydrationBoundary>
|
summary='This production detail route is isolated from the legacy quotation preview and approval mock flow.'
|
||||||
|
foundationItems={[
|
||||||
|
'Document sequence helper',
|
||||||
|
'Branch scope validation',
|
||||||
|
'Master option lookups',
|
||||||
|
'Audit trail foundation'
|
||||||
|
]}
|
||||||
|
nextStep='Implement production quotation detail with real approval, preview, and related-entity data.'
|
||||||
|
demoHref={`/dashboard/crm-demo/quotations/${id}`}
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 PageContainer from '@/components/layout/page-container';
|
||||||
import { crmReferenceQueryOptions, quotationsQueryOptions } from '@/features/crm/api/queries';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { QuotationsTablePage } from '@/features/crm/components/quotations-table';
|
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
|
||||||
import { searchParamsCache } from '@/lib/searchparams';
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM Quotations'
|
title: 'Dashboard: CRM Quotations'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function QuotationsRoute({
|
export default function QuotationsRoute() {
|
||||||
searchParams
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<SearchParams>;
|
|
||||||
}) {
|
|
||||||
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 (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Quotations'
|
pageTitle='Quotations'
|
||||||
pageDescription='Quotation table with filters, hot project indicator, and kanban toggle'
|
pageDescription='Production quotation flows will be built on foundation services, not on the legacy demo layer.'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<QuotationsTablePage />
|
title='Quotation module pending'
|
||||||
</HydrationBoundary>
|
summary='The mock quotation list, kanban, and detail experiences now live only under crm-demo.'
|
||||||
|
foundationItems={[
|
||||||
|
'Quotation status master options',
|
||||||
|
'Server-side document sequence generation',
|
||||||
|
'Organization and branch context',
|
||||||
|
'Audit log helper for future status changes'
|
||||||
|
]}
|
||||||
|
nextStep='Implement quotation persistence and workflow after enquiry production APIs are ready.'
|
||||||
|
demoHref='/dashboard/crm-demo/quotations'
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { DocumentSequencesPage } from '@/features/crm/components/settings-page';
|
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
|
||||||
|
|
||||||
export default function DocumentSequencesRoute() {
|
export default function DocumentSequencesRoute() {
|
||||||
const queryClient = getQueryClient();
|
|
||||||
void queryClient.prefetchQuery(crmReferenceQueryOptions());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Document Sequences'
|
pageTitle='Document Sequences'
|
||||||
pageDescription='Mock document sequence แยกตาม branch และ document type'
|
pageDescription='The sequence foundation is ready, but a production management UI has not been introduced yet.'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<DocumentSequencesPage />
|
title='Document sequence UI pending'
|
||||||
</HydrationBoundary>
|
summary='Sequence generation now exists in the foundation layer, but the old mock settings page has been isolated to the demo route.'
|
||||||
|
foundationItems={[
|
||||||
|
'Preview and generate sequence helpers',
|
||||||
|
'Organization-scoped sequence table',
|
||||||
|
'Branch-aware sequence support'
|
||||||
|
]}
|
||||||
|
nextStep='Add a production management page only when the product requires editable sequence settings.'
|
||||||
|
demoHref='/dashboard/crm-demo/settings/document-sequences'
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
|
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||||
import { TemplatesPage } from '@/features/crm/components/settings-page';
|
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
|
||||||
|
|
||||||
export default function TemplatesRoute() {
|
export default function TemplatesRoute() {
|
||||||
const queryClient = getQueryClient();
|
|
||||||
void queryClient.prefetchQuery(crmReferenceQueryOptions());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Quotation Templates'
|
pageTitle='Quotation Templates'
|
||||||
pageDescription='Mock quotation template, version และ placeholder mappings สำหรับต่อยอด PDF preview'
|
pageDescription='Template management is not part of the production path yet and no longer depends on legacy demo services.'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<CrmProductionPlaceholder
|
||||||
<TemplatesPage />
|
title='Template management pending'
|
||||||
</HydrationBoundary>
|
summary='The placeholder quotation template demo has been isolated away from the production CRM route tree.'
|
||||||
|
foundationItems={['Master options foundation', 'Sequence foundation', 'Audit foundation']}
|
||||||
|
nextStep='Introduce template persistence only when the production quotation/PDF flow is designed.'
|
||||||
|
demoHref='/dashboard/crm-demo/settings/templates'
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from "@/components/layout/page-container";
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import {
|
||||||
import { Input } from '@/components/ui/input';
|
Card,
|
||||||
import { workspacesInfoContent } from '@/config/infoconfig';
|
CardContent,
|
||||||
import { useSession } from 'next-auth/react';
|
CardDescription,
|
||||||
import { useRouter } from 'next/navigation';
|
CardHeader,
|
||||||
import { toast } from 'sonner';
|
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 = {
|
type OrganizationSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,12 +29,12 @@ type OrganizationSummary = {
|
|||||||
export default function WorkspacesPage() {
|
export default function WorkspacesPage() {
|
||||||
const { data: session, status, update } = useSession();
|
const { data: session, status, update } = useSession();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [workspaceName, setWorkspaceName] = useState('');
|
const [workspaceName, setWorkspaceName] = useState("");
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [organizations, setOrganizations] = useState<OrganizationSummary[]>([]);
|
const [organizations, setOrganizations] = useState<OrganizationSummary[]>([]);
|
||||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||||
|
|
||||||
const canManageOrganizations = session?.user?.systemRole === 'super_admin';
|
const canManageOrganizations = session?.user?.systemRole === "super_admin";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canManageOrganizations) {
|
if (!canManageOrganizations) {
|
||||||
@@ -42,14 +48,14 @@ export default function WorkspacesPage() {
|
|||||||
setIsLoadingOrganizations(true);
|
setIsLoadingOrganizations(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/organizations');
|
const response = await fetch("/api/organizations");
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
organizations?: OrganizationSummary[];
|
organizations?: OrganizationSummary[];
|
||||||
message?: string;
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.message ?? 'Unable to load organizations');
|
throw new Error(data.message ?? "Unable to load organizations");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -57,7 +63,11 @@ export default function WorkspacesPage() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
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 {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -75,30 +85,34 @@ export default function WorkspacesPage() {
|
|||||||
|
|
||||||
const createWorkspace = async () => {
|
const createWorkspace = async () => {
|
||||||
if (!workspaceName.trim()) {
|
if (!workspaceName.trim()) {
|
||||||
toast.error('Workspace name is required');
|
toast.error("Workspace name is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/organizations', {
|
const response = await fetch("/api/organizations", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: workspaceName })
|
body: JSON.stringify({ name: workspaceName }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = (await response.json().catch(() => null)) as { message?: string } | null;
|
const data = (await response.json().catch(() => null)) as {
|
||||||
throw new Error(data?.message ?? 'Unable to create workspace');
|
message?: string;
|
||||||
|
} | null;
|
||||||
|
throw new Error(data?.message ?? "Unable to create workspace");
|
||||||
}
|
}
|
||||||
|
|
||||||
setWorkspaceName('');
|
setWorkspaceName("");
|
||||||
await update();
|
await update();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
toast.success('Workspace created');
|
toast.success("Workspace created");
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
@@ -106,58 +120,65 @@ export default function WorkspacesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Workspaces'
|
pageTitle="Workspaces"
|
||||||
pageDescription='Manage your app-owned workspaces and switch your active organization.'
|
pageDescription="Manage your app-owned workspaces and switch your active organization."
|
||||||
infoContent={workspacesInfoContent}
|
infoContent={workspacesInfoContent}
|
||||||
isLoading={status === 'loading' || (canManageOrganizations && isLoadingOrganizations)}
|
isLoading={
|
||||||
|
status === "loading" ||
|
||||||
|
(canManageOrganizations && isLoadingOrganizations)
|
||||||
|
}
|
||||||
access={canManageOrganizations}
|
access={canManageOrganizations}
|
||||||
accessFallback={
|
accessFallback={
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center">
|
||||||
Only super admins can manage workspaces.
|
Only super admins can manage workspaces.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className='grid gap-6 xl:grid-cols-[1.2fr_0.8fr]'>
|
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Your workspaces</CardTitle>
|
<CardTitle>Your workspaces</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
The active workspace controls organization-scoped product data and access checks.
|
The active workspace controls organization-scoped product data and
|
||||||
|
access checks.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className="space-y-3">
|
||||||
{organizations.length ? (
|
{organizations.length ? (
|
||||||
organizations.map((organization) => {
|
organizations.map((organization) => {
|
||||||
const isActive = organization.id === session?.user?.activeOrganizationId;
|
const isActive =
|
||||||
|
organization.id === session?.user?.activeOrganizationId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={organization.id}
|
key={organization.id}
|
||||||
type='button'
|
type="button"
|
||||||
onClick={() => router.push('/dashboard/workspaces/team')}
|
onClick={() => router.push("/dashboard/workspaces/team")}
|
||||||
aria-label={`Open workspace ${organization.name}`}
|
aria-label={`Open workspace ${organization.name}`}
|
||||||
className={`w-full rounded-lg border p-4 text-left transition ${
|
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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className='flex items-center justify-between gap-4'>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className='font-semibold'>{organization.name}</div>
|
<div className="font-semibold">{organization.name}</div>
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className="text-muted-foreground text-sm">
|
||||||
Role: {organization.role} | Plan: {organization.plan}
|
Role: {organization.role}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-xs font-medium uppercase'>
|
<div className="text-xs font-medium uppercase">
|
||||||
{isActive ? 'Active' : 'Open'}
|
{isActive ? "Active" : "Open"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-sm'>
|
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
|
||||||
No workspace found yet. Create your first workspace to unlock organization-scoped
|
No workspace found yet. Create your first workspace to unlock
|
||||||
product management.
|
organization-scoped product management.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -167,23 +188,28 @@ export default function WorkspacesPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Create workspace</CardTitle>
|
<CardTitle>Create workspace</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
This flow is limited to super admins in the organization RBAC model.
|
This flow is limited to super admins in the organization RBAC
|
||||||
|
model.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className="space-y-4">
|
||||||
<div className='space-y-2'>
|
<div className="space-y-2">
|
||||||
<label htmlFor='workspace-name' className='text-sm font-medium'>
|
<label htmlFor="workspace-name" className="text-sm font-medium">
|
||||||
Workspace name
|
Workspace name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id='workspace-name'
|
id="workspace-name"
|
||||||
value={workspaceName}
|
value={workspaceName}
|
||||||
onChange={(event) => setWorkspaceName(event.target.value)}
|
onChange={(event) => setWorkspaceName(event.target.value)}
|
||||||
placeholder='Acme Studio'
|
placeholder="Acme Studio"
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button className='w-full' isLoading={isCreating} onClick={createWorkspace}>
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
isLoading={isCreating}
|
||||||
|
onClick={createWorkspace}
|
||||||
|
>
|
||||||
Create workspace
|
Create workspace
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavGroup } from '@/types';
|
import { NavGroup } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation configuration with RBAC support
|
* Navigation configuration with RBAC support
|
||||||
@@ -35,157 +35,49 @@ import { NavGroup } from '@/types';
|
|||||||
*/
|
*/
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: 'Overview',
|
label: "Overview",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: "Dashboard",
|
||||||
url: '/dashboard',
|
url: "/dashboard",
|
||||||
icon: 'dashboard',
|
icon: "dashboard",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
shortcut: ['d', 'd'],
|
shortcut: ["d", "d"],
|
||||||
items: []
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Workspaces',
|
title: "Workspaces",
|
||||||
url: '/dashboard/workspaces',
|
url: "/dashboard/workspaces",
|
||||||
icon: 'workspace',
|
icon: "workspace",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { systemRole: 'super_admin' }
|
access: { systemRole: "super_admin" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Teams',
|
title: "Teams",
|
||||||
url: '/dashboard/workspaces/team',
|
url: "/dashboard/workspaces/team",
|
||||||
icon: 'teams',
|
icon: "teams",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, role: 'admin' }
|
access: { requireOrg: true, role: "admin" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Users',
|
title: "Users",
|
||||||
url: '/dashboard/users',
|
url: "/dashboard/users",
|
||||||
icon: 'teams',
|
icon: "teams",
|
||||||
shortcut: ['u', 'u'],
|
shortcut: ["u", "u"],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, permission: 'users:manage' }
|
access: { requireOrg: true, permission: "users:manage" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Kanban',
|
title: "Kanban",
|
||||||
url: '/dashboard/kanban',
|
url: "/dashboard/kanban",
|
||||||
icon: 'kanban',
|
icon: "kanban",
|
||||||
shortcut: ['k', 'k'],
|
shortcut: ["k", "k"],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: []
|
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",
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
];
|
];
|
||||||
|
|||||||
298
src/db/seeds/foundation.seed.ts
Normal file
298
src/db/seeds/foundation.seed.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const postgres = require('postgres');
|
||||||
|
|
||||||
|
type SqlClient = ReturnType<typeof postgres>;
|
||||||
|
|
||||||
|
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<BranchSeedRow[]> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,12 +10,7 @@ import {
|
|||||||
getQuotationById,
|
getQuotationById,
|
||||||
getQuotations
|
getQuotations
|
||||||
} from './service';
|
} from './service';
|
||||||
import type {
|
import type { ApprovalFilters, CustomerFilters, EnquiryFilters, QuotationFilters } from './types';
|
||||||
ApprovalFilters,
|
|
||||||
CustomerFilters,
|
|
||||||
EnquiryFilters,
|
|
||||||
QuotationFilters
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export const crmKeys = {
|
export const crmKeys = {
|
||||||
all: ['crm'] as const,
|
all: ['crm'] as const,
|
||||||
@@ -86,33 +86,109 @@ export async function getCrmDashboardData(): Promise<CrmDashboardData> {
|
|||||||
|
|
||||||
return clone({
|
return clone({
|
||||||
metrics: [
|
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-1',
|
||||||
{ id: 'm-3', label: 'Pending Quotations', value: String(pendingQuotations.length), change: '+2', trend: 'up' },
|
label: 'Total Enquiries',
|
||||||
{ id: 'm-4', label: 'Pending Approval', value: String(crmState.approvals.filter((item) => item.status === 'pending').length), change: 'Needs review', trend: 'flat' },
|
value: String(crmState.enquiries.length),
|
||||||
{ id: 'm-5', label: 'Won Amount', value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '+18%', trend: 'up' },
|
change: '+12%',
|
||||||
{ id: 'm-6', label: 'Lost Amount', value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '-6%', trend: 'down' },
|
trend: 'up'
|
||||||
{ 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-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: [
|
enquiryPipeline: [
|
||||||
{ label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length },
|
{ 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: 'Qualifying',
|
||||||
{ label: 'Follow Up', value: crmState.enquiries.filter((item) => item.status === 'follow_up').length },
|
value: crmState.enquiries.filter((item) => item.status === 'qualifying').length
|
||||||
{ label: 'Converted', value: crmState.enquiries.filter((item) => item.status === 'converted').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: [
|
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: '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 }
|
{ label: 'Lost', value: crmState.quotations.filter((item) => item.status === 'lost').length }
|
||||||
],
|
],
|
||||||
salesPerformance: crmState.salespersons.map((salesperson) => ({
|
salesPerformance: crmState.salespersons.map((salesperson) => ({
|
||||||
label: salesperson.nickname,
|
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
|
secondaryValue: crmState.quotations
|
||||||
.filter((quotation) => quotation.salesmanId === salesperson.id)
|
.filter((quotation) => quotation.salesmanId === salesperson.id)
|
||||||
.reduce((sum, quotation) => sum + quotation.totalAmount, 0)
|
.reduce((sum, quotation) => sum + quotation.totalAmount, 0)
|
||||||
@@ -170,7 +246,9 @@ export async function getCustomerById(id: string): Promise<CustomerDetailRespons
|
|||||||
customer,
|
customer,
|
||||||
contacts: crmState.contacts.filter((contact) => contact.customerId === id),
|
contacts: crmState.contacts.filter((contact) => contact.customerId === id),
|
||||||
shares: crmState.contactShares.filter((share) =>
|
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) =>
|
shareLogs: crmState.contactShareLogs.filter((log) =>
|
||||||
crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id)
|
crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id)
|
||||||
@@ -190,14 +268,16 @@ export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedRe
|
|||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const search = filters.search.toLowerCase();
|
const search = filters.search.toLowerCase();
|
||||||
items = items.filter(
|
items = items.filter(
|
||||||
(item) => 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.status) items = items.filter((item) => item.status === filters.status);
|
||||||
if (filters.productType) items = items.filter((item) => item.productType === filters.productType);
|
if (filters.productType) items = items.filter((item) => item.productType === filters.productType);
|
||||||
if (filters.salesman) items = items.filter((item) => item.salesmanId === filters.salesman);
|
if (filters.salesman) items = items.filter((item) => item.salesmanId === filters.salesman);
|
||||||
if (filters.dateFrom) items = items.filter((item) => item.createdAt >= filters.dateFrom!);
|
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);
|
items = sortItems(items, filters.sort);
|
||||||
return clone(paginate(items, filters.page, filters.limit));
|
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.status) items = items.filter((quotation) => quotation.status === filters.status);
|
||||||
if (filters.quotationType) items = items.filter((quotation) => quotation.quotationType === filters.quotationType);
|
if (filters.quotationType)
|
||||||
if (filters.salesman) items = items.filter((quotation) => quotation.salesmanId === filters.salesman);
|
items = items.filter((quotation) => quotation.quotationType === filters.quotationType);
|
||||||
if (filters.hot) items = items.filter((quotation) => (filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject));
|
if (filters.salesman)
|
||||||
if (filters.dateFrom) items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!);
|
items = items.filter((quotation) => quotation.salesmanId === filters.salesman);
|
||||||
if (filters.dateTo) items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!);
|
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);
|
items = sortItems(items, filters.sort);
|
||||||
return clone(paginate(items, filters.page, filters.limit));
|
return clone(paginate(items, filters.page, filters.limit));
|
||||||
@@ -253,7 +340,9 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
|
|||||||
|
|
||||||
const enquiry = crmState.enquiries.find((item) => item.id === quotation.enquiryId)!;
|
const enquiry = crmState.enquiries.find((item) => item.id === quotation.enquiryId)!;
|
||||||
const customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))];
|
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({
|
return clone({
|
||||||
quotation,
|
quotation,
|
||||||
@@ -301,7 +390,9 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
|
|||||||
quotationType: 'official',
|
quotationType: 'official',
|
||||||
project: enquiry.title,
|
project: enquiry.title,
|
||||||
siteLocation: enquiry.projectLocation,
|
siteLocation: enquiry.projectLocation,
|
||||||
customerRoles: [{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }],
|
customerRoles: [
|
||||||
|
{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }
|
||||||
|
],
|
||||||
salesmanId: enquiry.salesmanId,
|
salesmanId: enquiry.salesmanId,
|
||||||
saleAdminId: 'sp-2',
|
saleAdminId: 'sp-2',
|
||||||
branchId: enquiry.branchId,
|
branchId: enquiry.branchId,
|
||||||
@@ -310,10 +401,30 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
|
|||||||
totalAmount: enquiry.expectedValue,
|
totalAmount: enquiry.expectedValue,
|
||||||
chancePercent: enquiry.chancePercent,
|
chancePercent: enquiry.chancePercent,
|
||||||
isHotProject: enquiry.chancePercent >= 70,
|
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: [
|
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'] }
|
{ id: `${nextId}-topic-3`, type: 'payment', label: 'Payment', items: ['Pending setup'] }
|
||||||
],
|
],
|
||||||
followUps: [],
|
followUps: [],
|
||||||
@@ -357,7 +468,13 @@ export async function submitQuotationApproval(quotationId: string) {
|
|||||||
quotation.approvalSteps.length > 0
|
quotation.approvalSteps.length > 0
|
||||||
? quotation.approvalSteps
|
? 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
|
: quotation
|
||||||
@@ -368,11 +485,11 @@ export async function approveQuotation(payload: ApprovalActionPayload) {
|
|||||||
crmState.quotations = crmState.quotations.map((quotation) => {
|
crmState.quotations = crmState.quotations.map((quotation) => {
|
||||||
if (quotation.id !== payload.quotationId) return 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')
|
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
|
||||||
? {
|
? {
|
||||||
...step,
|
...step,
|
||||||
status: 'approved',
|
status: 'approved' as const,
|
||||||
actedAt: '2026-06-11T11:00:00.000Z',
|
actedAt: '2026-06-11T11:00:00.000Z',
|
||||||
comment: payload.comment
|
comment: payload.comment
|
||||||
}
|
}
|
||||||
@@ -384,7 +501,9 @@ export async function approveQuotation(payload: ApprovalActionPayload) {
|
|||||||
...quotation,
|
...quotation,
|
||||||
approvalSteps: nextSteps,
|
approvalSteps: nextSteps,
|
||||||
status: hasPending ? 'pending_approval' : 'approved',
|
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.id === payload.quotationId
|
||||||
? {
|
? {
|
||||||
...quotation,
|
...quotation,
|
||||||
status: 'rejected',
|
status: 'rejected' as const,
|
||||||
approvalSteps: quotation.approvalSteps.map((step, index) =>
|
approvalSteps: quotation.approvalSteps.map((step, index) =>
|
||||||
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
|
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
|
||||||
? {
|
? {
|
||||||
...step,
|
...step,
|
||||||
status: 'rejected',
|
status: 'rejected' as const,
|
||||||
actedAt: '2026-06-11T11:30:00.000Z',
|
actedAt: '2026-06-11T11:30:00.000Z',
|
||||||
comment: payload.comment
|
comment: payload.comment
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,11 @@ function DecisionDialog({
|
|||||||
<DialogTitle>{label}</DialogTitle>
|
<DialogTitle>{label}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Input value={comment} onChange={(event) => setComment(event.target.value)} placeholder='Comment' />
|
<Input
|
||||||
|
value={comment}
|
||||||
|
onChange={(event) => setComment(event.target.value)}
|
||||||
|
placeholder='Comment'
|
||||||
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
|
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
|
||||||
Confirm
|
Confirm
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
||||||
import { crmDashboardQueryOptions } from '../api/queries';
|
import { crmDashboardQueryOptions } from '../api/queries';
|
||||||
@@ -58,11 +69,25 @@ export function CrmDashboardPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={chartConfig}>
|
<ChartContainer config={chartConfig}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={data.quotationPipeline} dataKey='value' nameKey='label' innerRadius={56} outerRadius={92}>
|
<Pie
|
||||||
|
data={data.quotationPipeline}
|
||||||
|
dataKey='value'
|
||||||
|
nameKey='label'
|
||||||
|
innerRadius={56}
|
||||||
|
outerRadius={92}
|
||||||
|
>
|
||||||
{data.quotationPipeline.map((entry, index) => (
|
{data.quotationPipeline.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={entry.label}
|
key={entry.label}
|
||||||
fill={['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'][index % 5]}
|
fill={
|
||||||
|
[
|
||||||
|
'var(--chart-1)',
|
||||||
|
'var(--chart-2)',
|
||||||
|
'var(--chart-3)',
|
||||||
|
'var(--chart-4)',
|
||||||
|
'var(--chart-5)'
|
||||||
|
][index % 5]
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
@@ -127,8 +152,20 @@ export function CrmDashboardPage() {
|
|||||||
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
||||||
<YAxis allowDecimals={false} />
|
<YAxis allowDecimals={false} />
|
||||||
<ChartTooltip content={<ChartTooltipContent />} />
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
<Area type='monotone' dataKey='value' stroke='var(--chart-1)' fill='var(--chart-1)' fillOpacity={0.2} />
|
<Area
|
||||||
<Area type='monotone' dataKey='secondaryValue' stroke='var(--chart-3)' fill='var(--chart-3)' fillOpacity={0.12} />
|
type='monotone'
|
||||||
|
dataKey='value'
|
||||||
|
stroke='var(--chart-1)'
|
||||||
|
fill='var(--chart-1)'
|
||||||
|
fillOpacity={0.2}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type='monotone'
|
||||||
|
dataKey='secondaryValue'
|
||||||
|
stroke='var(--chart-3)'
|
||||||
|
fill='var(--chart-3)'
|
||||||
|
fillOpacity={0.12}
|
||||||
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -136,7 +173,11 @@ export function CrmDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-4 xl:grid-cols-12'>
|
<div className='grid gap-4 xl:grid-cols-12'>
|
||||||
<SectionCard title='Pending Approvals' description='รายการที่ต้องตัดสินใจวันนี้' className='xl:col-span-4'>
|
<SectionCard
|
||||||
|
title='Pending Approvals'
|
||||||
|
description='รายการที่ต้องตัดสินใจวันนี้'
|
||||||
|
className='xl:col-span-4'
|
||||||
|
>
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{data.pendingApprovals.map((item) => (
|
{data.pendingApprovals.map((item) => (
|
||||||
<div key={item.id} className='rounded-lg border p-3'>
|
<div key={item.id} className='rounded-lg border p-3'>
|
||||||
@@ -144,7 +185,9 @@ export function CrmDashboardPage() {
|
|||||||
<p className='font-medium'>{item.quotationCode}</p>
|
<p className='font-medium'>{item.quotationCode}</p>
|
||||||
<QuotationStatusBadge status='pending_approval' />
|
<QuotationStatusBadge status='pending_approval' />
|
||||||
</div>
|
</div>
|
||||||
<p className='text-muted-foreground mt-1 text-sm'>{item.productType} / Approver Level {item.approverLevel}</p>
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
{item.productType} / Approver Level {item.approverLevel}
|
||||||
|
</p>
|
||||||
<p className='mt-2 text-sm font-medium'>{formatCurrency(item.amount)}</p>
|
<p className='mt-2 text-sm font-medium'>{formatCurrency(item.amount)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -155,7 +198,11 @@ export function CrmDashboardPage() {
|
|||||||
<RelatedQuotationList quotations={data.hotQuotations} />
|
<RelatedQuotationList quotations={data.hotQuotations} />
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title='Follow-ups Due Today' description='กิจกรรมติดตามที่ครบกำหนด' className='xl:col-span-3'>
|
<SectionCard
|
||||||
|
title='Follow-ups Due Today'
|
||||||
|
description='กิจกรรมติดตามที่ครบกำหนด'
|
||||||
|
className='xl:col-span-3'
|
||||||
|
>
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{data.dueFollowUps.map((item) => (
|
{data.dueFollowUps.map((item) => (
|
||||||
<div key={item.id} className='rounded-lg border p-3'>
|
<div key={item.id} className='rounded-lg border p-3'>
|
||||||
@@ -133,10 +133,7 @@ export function CustomerDetailPage({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className='mt-4 space-y-3'>
|
<div className='mt-4 space-y-3'>
|
||||||
{data.shares.length === 0 ? (
|
{data.shares.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState title='ยังไม่มีการแชร์ contact' description='เริ่มจากกรอกชื่อผู้ใช้ด้านบน' />
|
||||||
title='ยังไม่มีการแชร์ contact'
|
|
||||||
description='เริ่มจากกรอกชื่อผู้ใช้ด้านบน'
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
data.shares.map((share) => (
|
data.shares.map((share) => (
|
||||||
<div
|
<div
|
||||||
@@ -173,24 +173,14 @@ export function CustomerFormSheet({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormTextField name='province' label='Province' required placeholder='Bangkok' />
|
<FormTextField name='province' label='Province' required placeholder='Bangkok' />
|
||||||
<FormTextField
|
<FormTextField name='district' label='District' required placeholder='Chatuchak' />
|
||||||
name='district'
|
|
||||||
label='District'
|
|
||||||
required
|
|
||||||
placeholder='Chatuchak'
|
|
||||||
/>
|
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name='subDistrict'
|
name='subDistrict'
|
||||||
label='Sub District'
|
label='Sub District'
|
||||||
required
|
required
|
||||||
placeholder='Chom Phon'
|
placeholder='Chom Phon'
|
||||||
/>
|
/>
|
||||||
<FormTextField
|
<FormTextField name='postalCode' label='Postal Code' required placeholder='10900' />
|
||||||
name='postalCode'
|
|
||||||
label='Postal Code'
|
|
||||||
required
|
|
||||||
placeholder='10900'
|
|
||||||
/>
|
|
||||||
</form.Form>
|
</form.Form>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</div>
|
</div>
|
||||||
186
src/features/crm-demo/components/customers-table.tsx
Normal file
186
src/features/crm-demo/components/customers-table.tsx
Normal file
@@ -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<Customer | undefined>();
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<Customer>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'code',
|
||||||
|
header: 'Code',
|
||||||
|
cell: ({ row }) => <span className='font-medium'>{row.original.code}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Customer',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<p className='font-medium'>{row.original.name}</p>
|
||||||
|
<p className='text-muted-foreground text-sm'>{row.original.email}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'customerStatus',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => <CustomerStatusBadge status={row.original.customerStatus} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 ? <BranchBadge branch={branch} /> : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'contactIds',
|
||||||
|
header: 'Contacts',
|
||||||
|
cell: ({ row }) => row.original.contactIds.length
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex justify-end gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCustomer(row.original);
|
||||||
|
setSheetOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant='outline' size='sm' asChild>
|
||||||
|
<Link href={`/dashboard/crm-demo/customers/${row.original.id}`}>View</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className='flex flex-1 flex-col space-y-4'>
|
||||||
|
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||||
|
<div className='grid gap-3 md:grid-cols-3 lg:w-full'>
|
||||||
|
<Input
|
||||||
|
placeholder='ค้นหา customer หรือ code'
|
||||||
|
value={params.name ?? ''}
|
||||||
|
onChange={(event) => void setParams({ name: event.target.value || null, page: 1 })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background rounded-md border px-3 text-sm'
|
||||||
|
value={params.customerStatus ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
void setParams({
|
||||||
|
customerStatus: event.target.value || null,
|
||||||
|
page: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>ทุกสถานะ</option>
|
||||||
|
<option value='active'>Active</option>
|
||||||
|
<option value='prospect'>Prospect</option>
|
||||||
|
<option value='inactive'>Inactive</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background rounded-md border px-3 text-sm'
|
||||||
|
value={params.branch ?? ''}
|
||||||
|
onChange={(event) => void setParams({ branch: event.target.value || null, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value=''>ทุกสาขา</option>
|
||||||
|
{reference.branches.map((branch) => (
|
||||||
|
<option key={branch.id} value={branch.id}>
|
||||||
|
{branch.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCustomer(undefined);
|
||||||
|
setSheetOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.add className='mr-2 h-4 w-4' />
|
||||||
|
Create Customer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable table={table} />
|
||||||
|
<CustomerFormSheet
|
||||||
|
open={sheetOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setSheetOpen(open);
|
||||||
|
if (!open) setEditingCustomer(undefined);
|
||||||
|
}}
|
||||||
|
customer={editingCustomer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
src/features/crm-demo/components/enquiries-table.tsx
Normal file
157
src/features/crm-demo/components/enquiries-table.tsx
Normal file
@@ -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<Enquiry>[] = [
|
||||||
|
{ accessorKey: 'code', header: 'Code' },
|
||||||
|
{ accessorKey: 'title', header: 'Enquiry' },
|
||||||
|
{ accessorKey: 'productType', header: 'Product' },
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => <EnquiryStatusBadge status={row.original.status} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'chancePercent',
|
||||||
|
header: 'Chance',
|
||||||
|
cell: ({ row }) => <ChancePill value={row.original.chancePercent} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => <RowActions enquiry={row.original} />
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnIds = ['code', 'title', 'productType', 'status', 'chancePercent', 'actions'];
|
||||||
|
|
||||||
|
function RowActions({ enquiry }: { enquiry: Enquiry }) {
|
||||||
|
const mutation = useMutation({
|
||||||
|
...convertEnquiryMutation,
|
||||||
|
onSuccess: () => toast.success('สร้าง quotation mock จาก enquiry แล้ว')
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex justify-end gap-2'>
|
||||||
|
<Button variant='outline' size='sm' asChild>
|
||||||
|
<Link href={`/dashboard/crm-demo/enquiries/${enquiry.id}`}>View</Link>
|
||||||
|
</Button>
|
||||||
|
<Button size='sm' isLoading={mutation.isPending} onClick={() => mutation.mutate(enquiry.id)}>
|
||||||
|
Convert
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className='flex flex-1 flex-col space-y-4'>
|
||||||
|
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
|
||||||
|
<Input
|
||||||
|
placeholder='ค้นหา enquiry หรือ code'
|
||||||
|
value={params.name ?? ''}
|
||||||
|
onChange={(event) => void setParams({ name: event.target.value || null, page: 1 })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background rounded-md border px-3 text-sm'
|
||||||
|
value={params.status ?? ''}
|
||||||
|
onChange={(event) => void setParams({ status: event.target.value || null, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value=''>ทุกสถานะ</option>
|
||||||
|
<option value='new'>New</option>
|
||||||
|
<option value='qualifying'>Qualifying</option>
|
||||||
|
<option value='requirement'>Requirement</option>
|
||||||
|
<option value='follow_up'>Follow Up</option>
|
||||||
|
<option value='converted'>Converted</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background rounded-md border px-3 text-sm'
|
||||||
|
value={params.productType ?? ''}
|
||||||
|
onChange={(event) => void setParams({ productType: event.target.value || null, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value=''>ทุกสินค้า</option>
|
||||||
|
<option value='crane'>Crane</option>
|
||||||
|
<option value='dockdoor'>Dock Door</option>
|
||||||
|
<option value='solarcell'>Solar Cell</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background rounded-md border px-3 text-sm'
|
||||||
|
value={params.salesman ?? ''}
|
||||||
|
onChange={(event) => void setParams({ salesman: event.target.value || null, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value=''>ทุก salesperson</option>
|
||||||
|
{reference.salespersons.map((sales) => (
|
||||||
|
<option key={sales.id} value={sales.id}>
|
||||||
|
{sales.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
type='date'
|
||||||
|
value={params.dateFrom ?? ''}
|
||||||
|
onChange={(event) => void setParams({ dateFrom: event.target.value || null, page: 1 })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type='date'
|
||||||
|
value={params.dateTo ?? ''}
|
||||||
|
onChange={(event) => void setParams({ dateTo: event.target.value || null, page: 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<Button variant='outline' asChild>
|
||||||
|
<Link href='/dashboard/crm-demo/customers'>Create Enquiry</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DataTable table={table} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -211,9 +211,7 @@ export function QuotationDetailPage({ id }: { id: string }) {
|
|||||||
<span className='text-muted-foreground text-sm'>
|
<span className='text-muted-foreground text-sm'>
|
||||||
Revision R{String(data.quotation.revision).padStart(2, '0')}
|
Revision R{String(data.quotation.revision).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<span className='text-sm font-medium'>
|
<span className='text-sm font-medium'>{formatCurrency(data.quotation.totalAmount)}</span>
|
||||||
{formatCurrency(data.quotation.totalAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-4'>
|
<div className='mt-4'>
|
||||||
<InfoGrid
|
<InfoGrid
|
||||||
225
src/features/crm-demo/components/quotations-table.tsx
Normal file
225
src/features/crm-demo/components/quotations-table.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
'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<Quotation>[] = [
|
||||||
|
{ accessorKey: 'code', header: 'Code' },
|
||||||
|
{ accessorKey: 'project', header: 'Project' },
|
||||||
|
{ accessorKey: 'quotationType', header: 'Type' },
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => <QuotationStatusBadge status={row.original.status} />
|
||||||
|
},
|
||||||
|
{ accessorKey: 'revision', header: 'Rev' },
|
||||||
|
{
|
||||||
|
accessorKey: 'totalAmount',
|
||||||
|
header: 'Amount',
|
||||||
|
cell: ({ row }) => formatCurrency(row.original.totalAmount)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'isHotProject',
|
||||||
|
header: 'Hot',
|
||||||
|
cell: ({ row }) => <HotProjectPill active={row.original.isHotProject} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex justify-end gap-2'>
|
||||||
|
<Button variant='outline' size='sm' asChild>
|
||||||
|
<Link href={`/dashboard/crm-demo/quotations/${row.original.id}`}>View</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className='grid gap-4 xl:grid-cols-6'>
|
||||||
|
{statuses.map((status) => (
|
||||||
|
<Card key={status} className='space-y-3 p-3'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<p className='text-sm font-semibold capitalize'>{status.replace('_', ' ')}</p>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
{items.filter((item) => item.status === status).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{items
|
||||||
|
.filter((item) => item.status === status)
|
||||||
|
.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={`/dashboard/crm-demo/quotations/${item.id}`}
|
||||||
|
className='block rounded-lg border p-3 text-sm transition-colors hover:bg-muted/40'
|
||||||
|
>
|
||||||
|
<p className='font-medium'>{item.code}</p>
|
||||||
|
<p className='text-muted-foreground mt-1 line-clamp-2'>{item.project}</p>
|
||||||
|
<p className='mt-2 font-semibold'>{formatCurrency(item.totalAmount)}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className='flex flex-1 flex-col space-y-4'>
|
||||||
|
<div className='flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between'>
|
||||||
|
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-7 xl:w-full'>
|
||||||
|
<Input
|
||||||
|
placeholder='ค้นหา code หรือ project'
|
||||||
|
value={params.name ?? ''}
|
||||||
|
onChange={(event) => void setParams({ name: event.target.value || null, page: 1 })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background rounded-md border px-3 text-sm'
|
||||||
|
value={params.status ?? ''}
|
||||||
|
onChange={(event) => void setParams({ status: event.target.value || null, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value=''>ทุกสถานะ</option>
|
||||||
|
<option value='draft'>Draft</option>
|
||||||
|
<option value='pending_approval'>Pending Approval</option>
|
||||||
|
<option value='approved'>Approved</option>
|
||||||
|
<option value='sent'>Sent</option>
|
||||||
|
<option value='accepted'>Accepted</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background rounded-md border px-3 text-sm'
|
||||||
|
value={params.quotationType ?? ''}
|
||||||
|
onChange={(event) =>
|
||||||
|
void setParams({
|
||||||
|
quotationType: event.target.value || null,
|
||||||
|
page: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value=''>ทุกประเภท</option>
|
||||||
|
<option value='official'>Official</option>
|
||||||
|
<option value='budgetary'>Budgetary</option>
|
||||||
|
<option value='service'>Service</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background rounded-md border px-3 text-sm'
|
||||||
|
value={params.salesman ?? ''}
|
||||||
|
onChange={(event) => void setParams({ salesman: event.target.value || null, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value=''>ทุก salesperson</option>
|
||||||
|
{reference.salespersons.map((sales) => (
|
||||||
|
<option key={sales.id} value={sales.id}>
|
||||||
|
{sales.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className='border-input bg-background rounded-md border px-3 text-sm'
|
||||||
|
value={params.hot ?? ''}
|
||||||
|
onChange={(event) => void setParams({ hot: event.target.value || null, page: 1 })}
|
||||||
|
>
|
||||||
|
<option value=''>ทุกดีล</option>
|
||||||
|
<option value='yes'>Hot only</option>
|
||||||
|
<option value='no'>Non-hot</option>
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
type='date'
|
||||||
|
value={params.dateFrom ?? ''}
|
||||||
|
onChange={(event) => void setParams({ dateFrom: event.target.value || null, page: 1 })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type='date'
|
||||||
|
value={params.dateTo ?? ''}
|
||||||
|
onChange={(event) => void setParams({ dateTo: event.target.value || null, page: 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button variant='outline' asChild>
|
||||||
|
<Link href='/dashboard/crm-demo/enquiries'>Create quotation</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={params.view === 'table' ? 'default' : 'outline'}
|
||||||
|
onClick={() => void setParams({ view: 'table' })}
|
||||||
|
>
|
||||||
|
Table
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={params.view === 'kanban' ? 'default' : 'outline'}
|
||||||
|
onClick={() => void setParams({ view: 'kanban' })}
|
||||||
|
>
|
||||||
|
Kanban
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{params.view === 'kanban' ? <KanbanView items={data.items} /> : <DataTable table={table} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ export function MasterOptionsPage() {
|
|||||||
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
|
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title='Master Options' description='Status, product type, payment term, currency, tax rate'>
|
<SectionCard
|
||||||
|
title='Master Options'
|
||||||
|
description='Status, product type, payment term, currency, tax rate'
|
||||||
|
>
|
||||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-3'>
|
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-3'>
|
||||||
{data.masterOptions.map((item) => (
|
{data.masterOptions.map((item) => (
|
||||||
<div key={item.id} className='rounded-lg border p-4'>
|
<div key={item.id} className='rounded-lg border p-4'>
|
||||||
@@ -32,7 +35,8 @@ export function DocumentSequencesPage() {
|
|||||||
<div key={item.id} className='rounded-lg border p-4'>
|
<div key={item.id} className='rounded-lg border p-4'>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<p className='font-medium'>
|
<p className='font-medium'>
|
||||||
{item.prefix}{item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')}
|
{item.prefix}
|
||||||
|
{item.period}-{String(item.currentNumber).padStart(item.paddingLength, '0')}
|
||||||
</p>
|
</p>
|
||||||
<p className='text-muted-foreground text-sm'>{item.documentType}</p>
|
<p className='text-muted-foreground text-sm'>{item.documentType}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +86,11 @@ export function BranchBadge({ branch }: { branch: CrmBranch }) {
|
|||||||
|
|
||||||
export function CustomerStatusBadge({ status }: { status: Customer['customerStatus'] }) {
|
export function CustomerStatusBadge({ status }: { status: Customer['customerStatus'] }) {
|
||||||
return (
|
return (
|
||||||
<Badge variant={status === 'inactive' ? 'destructive' : status === 'prospect' ? 'outline' : 'secondary'}>
|
<Badge
|
||||||
|
variant={
|
||||||
|
status === 'inactive' ? 'destructive' : status === 'prospect' ? 'outline' : 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
{getCustomerStatusLabel(status)}
|
{getCustomerStatusLabel(status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -132,7 +136,14 @@ export function InfoGrid({
|
|||||||
cols?: 2 | 3 | 4;
|
cols?: 2 | 3 | 4;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-4', cols === 2 && 'md:grid-cols-2', cols === 3 && 'md:grid-cols-3', cols === 4 && 'md:grid-cols-2 xl:grid-cols-4')}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-4',
|
||||||
|
cols === 2 && 'md:grid-cols-2',
|
||||||
|
cols === 3 && 'md:grid-cols-3',
|
||||||
|
cols === 4 && 'md:grid-cols-2 xl:grid-cols-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.label} className='rounded-lg border bg-muted/20 p-3'>
|
<div key={item.label} className='rounded-lg border bg-muted/20 p-3'>
|
||||||
<p className='text-muted-foreground text-xs uppercase tracking-[0.18em]'>{item.label}</p>
|
<p className='text-muted-foreground text-xs uppercase tracking-[0.18em]'>{item.label}</p>
|
||||||
@@ -199,7 +210,10 @@ export function CustomerRoleCards({
|
|||||||
const contact = contacts.find((item) => item.id === role.contactId);
|
const contact = contacts.find((item) => item.id === role.contactId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${role.role}-${role.customerId}`} className='rounded-lg border bg-muted/20 p-4'>
|
<div
|
||||||
|
key={`${role.role}-${role.customerId}`}
|
||||||
|
className='rounded-lg border bg-muted/20 p-4'
|
||||||
|
>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<p className='text-sm font-semibold capitalize'>{role.role}</p>
|
<p className='text-sm font-semibold capitalize'>{role.role}</p>
|
||||||
<Badge variant='outline'>{customer?.code}</Badge>
|
<Badge variant='outline'>{customer?.code}</Badge>
|
||||||
@@ -226,7 +240,10 @@ export function QuotationPreviewPanel({
|
|||||||
approvalSteps: ApprovalStep[];
|
approvalSteps: ApprovalStep[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SectionCard title='Quotation Preview' description='Mock preview พร้อมต่อยอดไป pdfme/template mapping'>
|
<SectionCard
|
||||||
|
title='Quotation Preview'
|
||||||
|
description='Mock preview พร้อมต่อยอดไป pdfme/template mapping'
|
||||||
|
>
|
||||||
<div className='rounded-xl border bg-background p-5 shadow-sm'>
|
<div className='rounded-xl border bg-background p-5 shadow-sm'>
|
||||||
<div className='flex flex-wrap items-start justify-between gap-4 border-b pb-4'>
|
<div className='flex flex-wrap items-start justify-between gap-4 border-b pb-4'>
|
||||||
<div>
|
<div>
|
||||||
@@ -235,9 +252,16 @@ export function QuotationPreviewPanel({
|
|||||||
<p className='text-muted-foreground text-sm'>{quotation.project}</p>
|
<p className='text-muted-foreground text-sm'>{quotation.project}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-1 text-sm'>
|
<div className='space-y-1 text-sm'>
|
||||||
<p><span className='text-muted-foreground'>quotation_date:</span> {quotation.quotationDate}</p>
|
<p>
|
||||||
<p><span className='text-muted-foreground'>valid_until:</span> {quotation.validUntil}</p>
|
<span className='text-muted-foreground'>quotation_date:</span>{' '}
|
||||||
<p><span className='text-muted-foreground'>currency:</span> THB</p>
|
{quotation.quotationDate}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className='text-muted-foreground'>valid_until:</span> {quotation.validUntil}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className='text-muted-foreground'>currency:</span> THB
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -262,7 +286,10 @@ export function QuotationPreviewPanel({
|
|||||||
<p className='text-sm font-medium'>item_topic</p>
|
<p className='text-sm font-medium'>item_topic</p>
|
||||||
<div className='mt-2 space-y-2'>
|
<div className='mt-2 space-y-2'>
|
||||||
{quotation.items.map((item) => (
|
{quotation.items.map((item) => (
|
||||||
<div key={item.id} className='flex items-center justify-between gap-4 rounded-lg border p-3 text-sm'>
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className='flex items-center justify-between gap-4 rounded-lg border p-3 text-sm'
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className='font-medium'>{item.description}</p>
|
<p className='font-medium'>{item.description}</p>
|
||||||
<p className='text-muted-foreground'>
|
<p className='text-muted-foreground'>
|
||||||
@@ -279,7 +306,9 @@ export function QuotationPreviewPanel({
|
|||||||
<ul className='text-muted-foreground mt-1 space-y-1'>
|
<ul className='text-muted-foreground mt-1 space-y-1'>
|
||||||
{quotation.topics
|
{quotation.topics
|
||||||
.find((topic) => topic.type === 'exclusion')
|
.find((topic) => topic.type === 'exclusion')
|
||||||
?.items.map((item) => <li key={item}>- {item}</li>)}
|
?.items.map((item) => (
|
||||||
|
<li key={item}>- {item}</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 text-sm'>
|
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 text-sm'>
|
||||||
@@ -306,7 +335,10 @@ export function RelatedQuotationList({ quotations }: { quotations: Quotation[] }
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{quotations.map((quotation) => (
|
{quotations.map((quotation) => (
|
||||||
<div key={quotation.id} className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3'>
|
<div
|
||||||
|
key={quotation.id}
|
||||||
|
className='flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3'
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<p className='font-medium'>{quotation.code}</p>
|
<p className='font-medium'>{quotation.code}</p>
|
||||||
@@ -317,7 +349,7 @@ export function RelatedQuotationList({ quotations }: { quotations: Quotation[] }
|
|||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<p className='text-sm font-medium'>{formatCurrency(quotation.totalAmount)}</p>
|
<p className='text-sm font-medium'>{formatCurrency(quotation.totalAmount)}</p>
|
||||||
<Button variant='outline' asChild size='sm'>
|
<Button variant='outline' asChild size='sm'>
|
||||||
<Link href={`/dashboard/crm/quotations/${quotation.id}`}>
|
<Link href={`/dashboard/crm-demo/quotations/${quotation.id}`}>
|
||||||
<Icons.arrowRight className='mr-1 h-4 w-4' />
|
<Icons.arrowRight className='mr-1 h-4 w-4' />
|
||||||
เปิด
|
เปิด
|
||||||
</Link>
|
</Link>
|
||||||
@@ -347,11 +379,7 @@ export function AuditTimeline({ events }: { events: AuditEvent[] }) {
|
|||||||
actorName: event.actorName,
|
actorName: event.actorName,
|
||||||
timestamp: event.createdAt,
|
timestamp: event.createdAt,
|
||||||
tone:
|
tone:
|
||||||
event.action === 'APPROVE'
|
event.action === 'APPROVE' ? 'success' : event.action === 'REJECT' ? 'danger' : 'default'
|
||||||
? 'success'
|
|
||||||
: event.action === 'REJECT'
|
|
||||||
? 'danger'
|
|
||||||
: 'default'
|
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
1377
src/features/crm-demo/data/mock-crm.ts
Normal file
1377
src/features/crm-demo/data/mock-crm.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<ColumnDef<Customer>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: "code",
|
|
||||||
header: "Code",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="font-medium">{row.original.code}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
header: "Customer",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{row.original.name}</p>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{row.original.email}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customerStatus",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<CustomerStatusBadge status={row.original.customerStatus} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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 ? <BranchBadge branch={branch} /> : null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "contactIds",
|
|
||||||
header: "Contacts",
|
|
||||||
cell: ({ row }) => row.original.contactIds.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: "",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCustomer(row.original);
|
|
||||||
setSheetOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link href={`/dashboard/crm/customers/${row.original.id}`}>
|
|
||||||
View
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex flex-1 flex-col space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="grid gap-3 md:grid-cols-3 lg:w-full">
|
|
||||||
<Input
|
|
||||||
placeholder="ค้นหา customer หรือ code"
|
|
||||||
value={params.name ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ name: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
|
||||||
value={params.customerStatus ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({
|
|
||||||
customerStatus: event.target.value || null,
|
|
||||||
page: 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">ทุกสถานะ</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="prospect">Prospect</option>
|
|
||||||
<option value="inactive">Inactive</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
|
||||||
value={params.branch ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ branch: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">ทุกสาขา</option>
|
|
||||||
{reference.branches.map((branch) => (
|
|
||||||
<option key={branch.id} value={branch.id}>
|
|
||||||
{branch.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCustomer(undefined);
|
|
||||||
setSheetOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icons.add className="mr-2 h-4 w-4" />
|
|
||||||
Create Customer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable table={table} />
|
|
||||||
<CustomerFormSheet
|
|
||||||
open={sheetOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setSheetOpen(open);
|
|
||||||
if (!open) setEditingCustomer(undefined);
|
|
||||||
}}
|
|
||||||
customer={editingCustomer}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<Enquiry>[] = [
|
|
||||||
{ accessorKey: "code", header: "Code" },
|
|
||||||
{ accessorKey: "title", header: "Enquiry" },
|
|
||||||
{ accessorKey: "productType", header: "Product" },
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <EnquiryStatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "chancePercent",
|
|
||||||
header: "Chance",
|
|
||||||
cell: ({ row }) => <ChancePill value={row.original.chancePercent} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: "",
|
|
||||||
cell: ({ row }) => <RowActions enquiry={row.original} />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link href={`/dashboard/crm/enquiries/${enquiry.id}`}>View</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
isLoading={mutation.isPending}
|
|
||||||
onClick={() => mutation.mutate(enquiry.id)}
|
|
||||||
>
|
|
||||||
Convert
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex flex-1 flex-col space-y-4">
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
|
||||||
<Input
|
|
||||||
placeholder="ค้นหา enquiry หรือ code"
|
|
||||||
value={params.name ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ name: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
|
||||||
value={params.status ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ status: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">ทุกสถานะ</option>
|
|
||||||
<option value="new">New</option>
|
|
||||||
<option value="qualifying">Qualifying</option>
|
|
||||||
<option value="requirement">Requirement</option>
|
|
||||||
<option value="follow_up">Follow Up</option>
|
|
||||||
<option value="converted">Converted</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
|
||||||
value={params.productType ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ productType: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">ทุกสินค้า</option>
|
|
||||||
<option value="crane">Crane</option>
|
|
||||||
<option value="dockdoor">Dock Door</option>
|
|
||||||
<option value="solarcell">Solar Cell</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
|
||||||
value={params.salesman ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ salesman: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">ทุก salesperson</option>
|
|
||||||
{reference.salespersons.map((sales) => (
|
|
||||||
<option key={sales.id} value={sales.id}>
|
|
||||||
{sales.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={params.dateFrom ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ dateFrom: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={params.dateTo ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ dateTo: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href="/dashboard/crm/customers">Create Enquiry</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DataTable table={table} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<Quotation>[] = [
|
|
||||||
{ accessorKey: "code", header: "Code" },
|
|
||||||
{ accessorKey: "project", header: "Project" },
|
|
||||||
{ accessorKey: "quotationType", header: "Type" },
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <QuotationStatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{ accessorKey: "revision", header: "Rev" },
|
|
||||||
{
|
|
||||||
accessorKey: "totalAmount",
|
|
||||||
header: "Amount",
|
|
||||||
cell: ({ row }) => formatCurrency(row.original.totalAmount),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "isHotProject",
|
|
||||||
header: "Hot",
|
|
||||||
cell: ({ row }) => <HotProjectPill active={row.original.isHotProject} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: "",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link href={`/dashboard/crm/quotations/${row.original.id}`}>
|
|
||||||
View
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="grid gap-4 xl:grid-cols-6">
|
|
||||||
{statuses.map((status) => (
|
|
||||||
<Card key={status} className="space-y-3 p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm font-semibold capitalize">
|
|
||||||
{status.replace("_", " ")}
|
|
||||||
</p>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{items.filter((item) => item.status === status).length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{items
|
|
||||||
.filter((item) => item.status === status)
|
|
||||||
.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.id}
|
|
||||||
href={`/dashboard/crm/quotations/${item.id}`}
|
|
||||||
className="block rounded-lg border p-3 text-sm transition-colors hover:bg-muted/40"
|
|
||||||
>
|
|
||||||
<p className="font-medium">{item.code}</p>
|
|
||||||
<p className="text-muted-foreground mt-1 line-clamp-2">
|
|
||||||
{item.project}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 font-semibold">
|
|
||||||
{formatCurrency(item.totalAmount)}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex flex-1 flex-col space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-7 xl:w-full">
|
|
||||||
<Input
|
|
||||||
placeholder="ค้นหา code หรือ project"
|
|
||||||
value={params.name ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ name: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
|
||||||
value={params.status ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ status: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">ทุกสถานะ</option>
|
|
||||||
<option value="draft">Draft</option>
|
|
||||||
<option value="pending_approval">Pending Approval</option>
|
|
||||||
<option value="approved">Approved</option>
|
|
||||||
<option value="sent">Sent</option>
|
|
||||||
<option value="accepted">Accepted</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
|
||||||
value={params.quotationType ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({
|
|
||||||
quotationType: event.target.value || null,
|
|
||||||
page: 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">ทุกประเภท</option>
|
|
||||||
<option value="official">Official</option>
|
|
||||||
<option value="budgetary">Budgetary</option>
|
|
||||||
<option value="service">Service</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
|
||||||
value={params.salesman ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ salesman: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">ทุก salesperson</option>
|
|
||||||
{reference.salespersons.map((sales) => (
|
|
||||||
<option key={sales.id} value={sales.id}>
|
|
||||||
{sales.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="border-input bg-background rounded-md border px-3 text-sm"
|
|
||||||
value={params.hot ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ hot: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">ทุกดีล</option>
|
|
||||||
<option value="yes">Hot only</option>
|
|
||||||
<option value="no">Non-hot</option>
|
|
||||||
</select>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={params.dateFrom ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ dateFrom: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={params.dateTo ?? ""}
|
|
||||||
onChange={(event) =>
|
|
||||||
void setParams({ dateTo: event.target.value || null, page: 1 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href="/dashboard/crm/enquiries">Create quotation</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={params.view === "table" ? "default" : "outline"}
|
|
||||||
onClick={() => void setParams({ view: "table" })}
|
|
||||||
>
|
|
||||||
Table
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={params.view === "kanban" ? "default" : "outline"}
|
|
||||||
onClick={() => void setParams({ view: "kanban" })}
|
|
||||||
>
|
|
||||||
Kanban
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{params.view === "kanban" ? (
|
|
||||||
<KanbanView items={data.items} />
|
|
||||||
) : (
|
|
||||||
<DataTable table={table} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -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 (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{summary}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='text-sm font-medium'>Production path status</div>
|
||||||
|
<div className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
This route is isolated from legacy CRM mock services and is now reserved for
|
||||||
|
foundation-backed implementation only.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg border p-4'>
|
||||||
|
<div className='text-sm font-medium'>Reusable foundation ready</div>
|
||||||
|
<ul className='text-muted-foreground mt-2 space-y-1 text-sm'>
|
||||||
|
{foundationItems.map((item) => (
|
||||||
|
<li key={item}>- {item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg border border-dashed p-4'>
|
||||||
|
<div className='text-sm font-medium'>Next implementation step</div>
|
||||||
|
<div className='text-muted-foreground mt-1 text-sm'>{nextStep}</div>
|
||||||
|
</div>
|
||||||
|
{demoHref ? (
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<Button variant='outline' asChild>
|
||||||
|
<Link href={demoHref}>Open legacy demo route</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,15 @@ export const searchParams = {
|
|||||||
name: parseAsString,
|
name: parseAsString,
|
||||||
gender: parseAsString,
|
gender: parseAsString,
|
||||||
category: parseAsString,
|
category: parseAsString,
|
||||||
|
customerStatus: parseAsString,
|
||||||
|
branch: parseAsString,
|
||||||
|
productType: parseAsString,
|
||||||
|
salesman: parseAsString,
|
||||||
|
dateFrom: parseAsString,
|
||||||
|
dateTo: parseAsString,
|
||||||
|
quotationType: parseAsString,
|
||||||
|
hot: parseAsString,
|
||||||
|
view: parseAsString,
|
||||||
status: parseAsString,
|
status: parseAsString,
|
||||||
assetType: parseAsString,
|
assetType: parseAsString,
|
||||||
role: parseAsString,
|
role: parseAsString,
|
||||||
|
|||||||
Reference in New Issue
Block a user