task-b.1
complere
This commit is contained in:
phaichayon
2026-06-15 11:19:31 +07:00
parent 89b39cad38
commit b8cd39eaa4
55 changed files with 4295 additions and 1848 deletions

View File

@@ -0,0 +1,8 @@
Current:
branch stored in ms_options
Future:
organization_branches table
Reason:
branch will contain business metadata and workflow configuration

View 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

View 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

View File

@@ -16,7 +16,8 @@
"migrate": "npx drizzle-kit migrate",
"studio": "npx drizzle-kit studio",
"seed:super-admin": "node scripts/seed-super-admin.js",
"setup:db": "npm run migrate && npm run seed:super-admin"
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
"setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -58,6 +59,7 @@
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-table": "^8.21.3",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -92,8 +94,7 @@
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.12",
"bcryptjs": "^3.0.2"
"zustand": "^5.0.12"
},
"devDependencies": {
"@faker-js/faker": "^9.9.0",

468
plans/task-b.1.md Normal file
View 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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,26 +1,28 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { approvalsQueryOptions, crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { ApprovalsPage } from '@/features/crm/components/approvals-page';
import { getQueryClient } from '@/lib/query-client';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
export const metadata = {
title: 'Dashboard: CRM Approvals'
};
export default function ApprovalsRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(approvalsQueryOptions({ page: 1, limit: 10 }));
return (
<PageContainer
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)}>
<ApprovalsPage />
</HydrationBoundary>
<CrmProductionPlaceholder
title='Approval workflow pending'
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>
);
}

View File

@@ -1,24 +1,30 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { customerByIdOptions } from '@/features/crm/api/queries';
import { CustomerDetailPage } from '@/features/crm/components/customer-detail';
import { getQueryClient } from '@/lib/query-client';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
type PageProps = { params: Promise<{ id: string }> };
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function CustomerDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(customerByIdOptions(id));
return (
<PageContainer
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)}>
<CustomerDetailPage id={id} />
</HydrationBoundary>
<CrmProductionPlaceholder
title='Customer detail pending'
summary='This detail route is intentionally isolated from legacy demo state until the production Customer module is implemented.'
foundationItems={[
'Current organization context',
'Permission checks',
'Branch validation abstraction',
'Audit log helper'
]}
nextStep='Implement customer detail using real customer, contact, enquiry, and audit data from production APIs.'
demoHref={`/dashboard/crm-demo/customers/${id}`}
/>
</PageContainer>
);
}

View File

@@ -1,49 +1,29 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, customersQueryOptions } from '@/features/crm/api/queries';
import { CustomersTablePage } from '@/features/crm/components/customers-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
export const metadata = {
title: 'Dashboard: CRM Customers'
};
export default async function CustomersRoute({
searchParams
}: {
searchParams: Promise<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 })
})
);
export default function CustomersRoute() {
return (
<PageContainer
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)}>
<CustomersTablePage />
</HydrationBoundary>
<CrmProductionPlaceholder
title='Customer module pending'
summary='The old customer list and form flow has been moved to the demo path so this route no longer depends on mock services.'
foundationItems={[
'Organization-first access boundary',
'Customer status options from master options',
'Branch scope abstraction for customer ownership',
'Document code generation for customer records',
'Audit hooks for future mutations'
]}
nextStep='Implement Customer list, detail, and create/edit flows with real route handlers and persistence.'
demoHref='/dashboard/crm-demo/customers'
/>
</PageContainer>
);
}

View File

@@ -1,24 +1,30 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { enquiryByIdOptions } from '@/features/crm/api/queries';
import { EnquiryDetailPage } from '@/features/crm/components/enquiry-detail';
import { getQueryClient } from '@/lib/query-client';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
type PageProps = { params: Promise<{ id: string }> };
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function EnquiryDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(enquiryByIdOptions(id));
return (
<PageContainer
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)}>
<EnquiryDetailPage id={id} />
</HydrationBoundary>
<CrmProductionPlaceholder
title='Enquiry detail pending'
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>
);
}

View File

@@ -1,55 +1,29 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm/api/queries';
import { EnquiriesTablePage } from '@/features/crm/components/enquiries-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
export const metadata = {
title: 'Dashboard: CRM Enquiries'
};
export default async function EnquiriesRoute({
searchParams
}: {
searchParams: Promise<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 })
})
);
export default function EnquiriesRoute() {
return (
<PageContainer
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)}>
<EnquiriesTablePage />
</HydrationBoundary>
<CrmProductionPlaceholder
title='Enquiry module pending'
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>
);
}

View File

@@ -1,25 +1,31 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { crmDashboardQueryOptions } from '@/features/crm/api/queries';
import { CrmDashboardPage } from '@/features/crm/components/crm-dashboard';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
export const metadata = {
title: 'Dashboard: CRM'
};
export default function CrmDashboardRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmDashboardQueryOptions());
return (
<PageContainer
pageTitle='CRM Dashboard'
pageDescription='ภาพรวม workflow ของ Customer → Enquiry → Quotation → Approval → Won/Lost'
pageTitle='CRM Foundation'
pageDescription='Production CRM routes are now isolated from legacy mock state and ready for foundation-backed modules.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CrmDashboardPage />
</HydrationBoundary>
<CrmProductionPlaceholder
title='CRM production workspace'
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>
);
}

View File

@@ -1,24 +1,30 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { quotationByIdOptions } from '@/features/crm/api/queries';
import { QuotationDetailPage } from '@/features/crm/components/quotation-detail';
import { getQueryClient } from '@/lib/query-client';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
type PageProps = { params: Promise<{ id: string }> };
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function QuotationDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(quotationByIdOptions(id));
return (
<PageContainer
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)}>
<QuotationDetailPage id={id} />
</HydrationBoundary>
<CrmProductionPlaceholder
title='Quotation detail pending'
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>
);
}

View File

@@ -1,57 +1,28 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, quotationsQueryOptions } from '@/features/crm/api/queries';
import { QuotationsTablePage } from '@/features/crm/components/quotations-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
export const metadata = {
title: 'Dashboard: CRM Quotations'
};
export default async function QuotationsRoute({
searchParams
}: {
searchParams: Promise<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 })
})
);
export default function QuotationsRoute() {
return (
<PageContainer
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)}>
<QuotationsTablePage />
</HydrationBoundary>
<CrmProductionPlaceholder
title='Quotation module pending'
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>
);
}

View File

@@ -1,21 +1,23 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { DocumentSequencesPage } from '@/features/crm/components/settings-page';
import { getQueryClient } from '@/lib/query-client';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
export default function DocumentSequencesRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
return (
<PageContainer
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)}>
<DocumentSequencesPage />
</HydrationBoundary>
<CrmProductionPlaceholder
title='Document sequence UI pending'
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>
);
}

View File

@@ -1,21 +1,19 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { TemplatesPage } from '@/features/crm/components/settings-page';
import { getQueryClient } from '@/lib/query-client';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
export default function TemplatesRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
return (
<PageContainer
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)}>
<TemplatesPage />
</HydrationBoundary>
<CrmProductionPlaceholder
title='Template management pending'
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>
);
}

View File

@@ -1,14 +1,20 @@
'use client';
"use client";
import { useEffect, useState } from 'react';
import PageContainer from '@/components/layout/page-container';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { workspacesInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { useEffect, useState } from "react";
import PageContainer from "@/components/layout/page-container";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { workspacesInfoContent } from "@/config/infoconfig";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
type OrganizationSummary = {
id: string;
@@ -23,12 +29,12 @@ type OrganizationSummary = {
export default function WorkspacesPage() {
const { data: session, status, update } = useSession();
const router = useRouter();
const [workspaceName, setWorkspaceName] = useState('');
const [workspaceName, setWorkspaceName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [organizations, setOrganizations] = useState<OrganizationSummary[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const canManageOrganizations = session?.user?.systemRole === 'super_admin';
const canManageOrganizations = session?.user?.systemRole === "super_admin";
useEffect(() => {
if (!canManageOrganizations) {
@@ -42,14 +48,14 @@ export default function WorkspacesPage() {
setIsLoadingOrganizations(true);
try {
const response = await fetch('/api/organizations');
const response = await fetch("/api/organizations");
const data = (await response.json()) as {
organizations?: OrganizationSummary[];
message?: string;
};
if (!response.ok) {
throw new Error(data.message ?? 'Unable to load organizations');
throw new Error(data.message ?? "Unable to load organizations");
}
if (!cancelled) {
@@ -57,7 +63,11 @@ export default function WorkspacesPage() {
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : 'Unable to load organizations');
toast.error(
error instanceof Error
? error.message
: "Unable to load organizations",
);
}
} finally {
if (!cancelled) {
@@ -75,30 +85,34 @@ export default function WorkspacesPage() {
const createWorkspace = async () => {
if (!workspaceName.trim()) {
toast.error('Workspace name is required');
toast.error("Workspace name is required");
return;
}
setIsCreating(true);
try {
const response = await fetch('/api/organizations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: workspaceName })
const response = await fetch("/api/organizations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: workspaceName }),
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(data?.message ?? 'Unable to create workspace');
const data = (await response.json().catch(() => null)) as {
message?: string;
} | null;
throw new Error(data?.message ?? "Unable to create workspace");
}
setWorkspaceName('');
setWorkspaceName("");
await update();
router.refresh();
toast.success('Workspace created');
toast.success("Workspace created");
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to create workspace');
toast.error(
error instanceof Error ? error.message : "Unable to create workspace",
);
} finally {
setIsCreating(false);
}
@@ -106,58 +120,65 @@ export default function WorkspacesPage() {
return (
<PageContainer
pageTitle='Workspaces'
pageDescription='Manage your app-owned workspaces and switch your active organization.'
pageTitle="Workspaces"
pageDescription="Manage your app-owned workspaces and switch your active organization."
infoContent={workspacesInfoContent}
isLoading={status === 'loading' || (canManageOrganizations && isLoadingOrganizations)}
isLoading={
status === "loading" ||
(canManageOrganizations && isLoadingOrganizations)
}
access={canManageOrganizations}
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.
</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>
<CardHeader>
<CardTitle>Your workspaces</CardTitle>
<CardDescription>
The active workspace controls organization-scoped product data and access checks.
The active workspace controls organization-scoped product data and
access checks.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
<CardContent className="space-y-3">
{organizations.length ? (
organizations.map((organization) => {
const isActive = organization.id === session?.user?.activeOrganizationId;
const isActive =
organization.id === session?.user?.activeOrganizationId;
return (
<button
key={organization.id}
type='button'
onClick={() => router.push('/dashboard/workspaces/team')}
type="button"
onClick={() => router.push("/dashboard/workspaces/team")}
aria-label={`Open workspace ${organization.name}`}
className={`w-full rounded-lg border p-4 text-left transition ${
isActive ? 'border-primary bg-primary/5' : 'hover:bg-accent'
isActive
? "border-primary bg-primary/5"
: "hover:bg-accent"
}`}
>
<div className='flex items-center justify-between gap-4'>
<div className="flex items-center justify-between gap-4">
<div>
<div className='font-semibold'>{organization.name}</div>
<div className='text-muted-foreground text-sm'>
Role: {organization.role} | Plan: {organization.plan}
<div className="font-semibold">{organization.name}</div>
<div className="text-muted-foreground text-sm">
Role: {organization.role}
</div>
</div>
<div className='text-xs font-medium uppercase'>
{isActive ? 'Active' : 'Open'}
<div className="text-xs font-medium uppercase">
{isActive ? "Active" : "Open"}
</div>
</div>
</button>
);
})
) : (
<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
product management.
<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 product management.
</div>
)}
</CardContent>
@@ -167,23 +188,28 @@ export default function WorkspacesPage() {
<CardHeader>
<CardTitle>Create workspace</CardTitle>
<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>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='workspace-name' className='text-sm font-medium'>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="workspace-name" className="text-sm font-medium">
Workspace name
</label>
<Input
id='workspace-name'
id="workspace-name"
value={workspaceName}
onChange={(event) => setWorkspaceName(event.target.value)}
placeholder='Acme Studio'
placeholder="Acme Studio"
disabled={isCreating}
/>
</div>
<Button className='w-full' isLoading={isCreating} onClick={createWorkspace}>
<Button
className="w-full"
isLoading={isCreating}
onClick={createWorkspace}
>
Create workspace
</Button>
</CardContent>

View File

@@ -1,4 +1,4 @@
import { NavGroup } from '@/types';
import { NavGroup } from "@/types";
/**
* Navigation configuration with RBAC support
@@ -35,157 +35,49 @@ import { NavGroup } from '@/types';
*/
export const navGroups: NavGroup[] = [
{
label: 'Overview',
label: "Overview",
items: [
{
title: 'Dashboard',
url: '/dashboard',
icon: 'dashboard',
title: "Dashboard",
url: "/dashboard",
icon: "dashboard",
isActive: false,
shortcut: ['d', 'd'],
items: []
shortcut: ["d", "d"],
items: [],
},
{
title: 'Workspaces',
url: '/dashboard/workspaces',
icon: 'workspace',
title: "Workspaces",
url: "/dashboard/workspaces",
icon: "workspace",
isActive: false,
items: [],
access: { systemRole: 'super_admin' }
access: { systemRole: "super_admin" },
},
{
title: 'Teams',
url: '/dashboard/workspaces/team',
icon: 'teams',
title: "Teams",
url: "/dashboard/workspaces/team",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true, role: 'admin' }
access: { requireOrg: true, role: "admin" },
},
{
title: 'Users',
url: '/dashboard/users',
icon: 'teams',
shortcut: ['u', 'u'],
title: "Users",
url: "/dashboard/users",
icon: "teams",
shortcut: ["u", "u"],
isActive: false,
items: [],
access: { requireOrg: true, permission: 'users:manage' }
access: { requireOrg: true, permission: "users:manage" },
},
{
title: 'Kanban',
url: '/dashboard/kanban',
icon: 'kanban',
shortcut: ['k', 'k'],
title: "Kanban",
url: "/dashboard/kanban",
icon: "kanban",
shortcut: ["k", "k"],
isActive: false,
items: []
}
// {
// title: "Chat",
// url: "/dashboard/chat",
// icon: "chat",
// shortcut: ["c", "c"],
// isActive: false,
// items: [],
// },
]
}
// {
// label: "Elements",
// items: [
// {
// title: "Forms",
// url: "#",
// icon: "forms",
// isActive: true,
// items: [
// {
// title: "Basic Form",
// url: "/dashboard/forms/basic",
// icon: "forms",
// shortcut: ["f", "f"],
// },
// {
// title: "Multi-Step Form",
// url: "/dashboard/forms/multi-step",
// icon: "forms",
// },
// {
// title: "Sheet & Dialog",
// url: "/dashboard/forms/sheet-form",
// icon: "forms",
// },
// {
// title: "Advanced Patterns",
// url: "/dashboard/forms/advanced",
// icon: "forms",
// },
// ],
// },
// {
// title: "React Query",
// url: "/dashboard/react-query",
// icon: "code",
// isActive: false,
// items: [],
// },
// {
// title: "Icons",
// url: "/dashboard/elements/icons",
// icon: "palette",
// isActive: false,
// items: [],
// },
// ],
// },
// {
// label: "",
// items: [
// {
// title: "Pro",
// url: "#",
// icon: "pro",
// isActive: true,
// items: [
// {
// title: "Exclusive",
// url: "/dashboard/exclusive",
// icon: "exclusive",
// shortcut: ["e", "e"],
// },
// ],
// },
// {
// title: "Account",
// url: "#",
// icon: "account",
// isActive: true,
// items: [
// {
// title: "Profile",
// url: "/dashboard/profile",
// icon: "profile",
// shortcut: ["m", "m"],
// },
// {
// title: "Notifications",
// url: "/dashboard/notifications",
// icon: "notification",
// shortcut: ["n", "n"],
// },
// {
// title: "Billing",
// url: "/dashboard/billing",
// icon: "billing",
// shortcut: ["b", "b"],
// access: { requireOrg: true, role: "admin" },
// },
// {
// title: "Login",
// shortcut: ["l", "l"],
// url: "/",
// icon: "login",
// },
// ],
// },
// ],
// },
items: [],
},
],
},
];

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

View File

@@ -10,12 +10,7 @@ import {
getQuotationById,
getQuotations
} from './service';
import type {
ApprovalFilters,
CustomerFilters,
EnquiryFilters,
QuotationFilters
} from './types';
import type { ApprovalFilters, CustomerFilters, EnquiryFilters, QuotationFilters } from './types';
export const crmKeys = {
all: ['crm'] as const,

View File

@@ -86,33 +86,109 @@ export async function getCrmDashboardData(): Promise<CrmDashboardData> {
return clone({
metrics: [
{ id: 'm-1', label: 'Total Enquiries', value: String(crmState.enquiries.length), change: '+12%', trend: 'up' },
{ id: 'm-2', label: 'Open Enquiries', value: String(openEnquiries.length), change: '+3', trend: 'up' },
{ id: 'm-3', label: 'Pending Quotations', value: String(pendingQuotations.length), change: '+2', trend: 'up' },
{ id: 'm-4', label: 'Pending Approval', value: String(crmState.approvals.filter((item) => item.status === 'pending').length), change: 'Needs review', trend: 'flat' },
{ id: 'm-5', label: 'Won Amount', value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '+18%', trend: 'up' },
{ id: 'm-6', label: 'Lost Amount', value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`, change: '-6%', trend: 'down' },
{ id: 'm-7', label: 'Conversion Rate', value: `${conversionRate}%`, change: '+4pts', trend: 'up' },
{ id: 'm-8', label: 'Hot Projects', value: String(hotQuotations.length), change: 'Focus', trend: 'flat' },
{ id: 'm-9', label: 'Follow-up Due Today', value: String(dueFollowUps.length), change: 'Today', trend: 'flat' }
{
id: 'm-1',
label: 'Total Enquiries',
value: String(crmState.enquiries.length),
change: '+12%',
trend: 'up'
},
{
id: 'm-2',
label: 'Open Enquiries',
value: String(openEnquiries.length),
change: '+3',
trend: 'up'
},
{
id: 'm-3',
label: 'Pending Quotations',
value: String(pendingQuotations.length),
change: '+2',
trend: 'up'
},
{
id: 'm-4',
label: 'Pending Approval',
value: String(crmState.approvals.filter((item) => item.status === 'pending').length),
change: 'Needs review',
trend: 'flat'
},
{
id: 'm-5',
label: 'Won Amount',
value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`,
change: '+18%',
trend: 'up'
},
{
id: 'm-6',
label: 'Lost Amount',
value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`,
change: '-6%',
trend: 'down'
},
{
id: 'm-7',
label: 'Conversion Rate',
value: `${conversionRate}%`,
change: '+4pts',
trend: 'up'
},
{
id: 'm-8',
label: 'Hot Projects',
value: String(hotQuotations.length),
change: 'Focus',
trend: 'flat'
},
{
id: 'm-9',
label: 'Follow-up Due Today',
value: String(dueFollowUps.length),
change: 'Today',
trend: 'flat'
}
],
enquiryPipeline: [
{ label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length },
{ label: 'Qualifying', value: crmState.enquiries.filter((item) => item.status === 'qualifying').length },
{ label: 'Requirement', value: crmState.enquiries.filter((item) => item.status === 'requirement').length },
{ label: 'Follow Up', value: crmState.enquiries.filter((item) => item.status === 'follow_up').length },
{ label: 'Converted', value: crmState.enquiries.filter((item) => item.status === 'converted').length }
{
label: 'Qualifying',
value: crmState.enquiries.filter((item) => item.status === 'qualifying').length
},
{
label: 'Requirement',
value: crmState.enquiries.filter((item) => item.status === 'requirement').length
},
{
label: 'Follow Up',
value: crmState.enquiries.filter((item) => item.status === 'follow_up').length
},
{
label: 'Converted',
value: crmState.enquiries.filter((item) => item.status === 'converted').length
}
],
quotationPipeline: [
{ label: 'Draft', value: crmState.quotations.filter((item) => item.status === 'draft').length },
{ label: 'Pending', value: crmState.quotations.filter((item) => item.status === 'pending_approval').length },
{
label: 'Draft',
value: crmState.quotations.filter((item) => item.status === 'draft').length
},
{
label: 'Pending',
value: crmState.quotations.filter((item) => item.status === 'pending_approval').length
},
{ label: 'Sent', value: crmState.quotations.filter((item) => item.status === 'sent').length },
{ label: 'Accepted', value: crmState.quotations.filter((item) => item.status === 'accepted').length },
{
label: 'Accepted',
value: crmState.quotations.filter((item) => item.status === 'accepted').length
},
{ label: 'Lost', value: crmState.quotations.filter((item) => item.status === 'lost').length }
],
salesPerformance: crmState.salespersons.map((salesperson) => ({
label: salesperson.nickname,
value: crmState.quotations.filter((quotation) => quotation.salesmanId === salesperson.id).length,
value: crmState.quotations.filter((quotation) => quotation.salesmanId === salesperson.id)
.length,
secondaryValue: crmState.quotations
.filter((quotation) => quotation.salesmanId === salesperson.id)
.reduce((sum, quotation) => sum + quotation.totalAmount, 0)
@@ -170,7 +246,9 @@ export async function getCustomerById(id: string): Promise<CustomerDetailRespons
customer,
contacts: crmState.contacts.filter((contact) => contact.customerId === id),
shares: crmState.contactShares.filter((share) =>
crmState.contacts.some((contact) => contact.id === share.contactId && contact.customerId === id)
crmState.contacts.some(
(contact) => contact.id === share.contactId && contact.customerId === id
)
),
shareLogs: crmState.contactShareLogs.filter((log) =>
crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id)
@@ -190,14 +268,16 @@ export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedRe
if (filters.search) {
const search = filters.search.toLowerCase();
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.productType) items = items.filter((item) => item.productType === filters.productType);
if (filters.salesman) items = items.filter((item) => item.salesmanId === filters.salesman);
if (filters.dateFrom) items = items.filter((item) => item.createdAt >= filters.dateFrom!);
if (filters.dateTo) items = items.filter((item) => item.createdAt <= `${filters.dateTo!}T23:59:59.999Z`);
if (filters.dateTo)
items = items.filter((item) => item.createdAt <= `${filters.dateTo!}T23:59:59.999Z`);
items = sortItems(items, filters.sort);
return clone(paginate(items, filters.page, filters.limit));
@@ -235,11 +315,18 @@ export async function getQuotations(
);
}
if (filters.status) items = items.filter((quotation) => quotation.status === filters.status);
if (filters.quotationType) items = items.filter((quotation) => quotation.quotationType === filters.quotationType);
if (filters.salesman) items = items.filter((quotation) => quotation.salesmanId === filters.salesman);
if (filters.hot) items = items.filter((quotation) => (filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject));
if (filters.dateFrom) items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!);
if (filters.dateTo) items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!);
if (filters.quotationType)
items = items.filter((quotation) => quotation.quotationType === filters.quotationType);
if (filters.salesman)
items = items.filter((quotation) => quotation.salesmanId === filters.salesman);
if (filters.hot)
items = items.filter((quotation) =>
filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject
);
if (filters.dateFrom)
items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!);
if (filters.dateTo)
items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!);
items = sortItems(items, filters.sort);
return clone(paginate(items, filters.page, filters.limit));
@@ -253,7 +340,9 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
const enquiry = crmState.enquiries.find((item) => item.id === quotation.enquiryId)!;
const customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))];
const contactIds = [...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))] as string[];
const contactIds = [
...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))
] as string[];
return clone({
quotation,
@@ -301,7 +390,9 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
quotationType: 'official',
project: enquiry.title,
siteLocation: enquiry.projectLocation,
customerRoles: [{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }],
customerRoles: [
{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }
],
salesmanId: enquiry.salesmanId,
saleAdminId: 'sp-2',
branchId: enquiry.branchId,
@@ -310,10 +401,30 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
totalAmount: enquiry.expectedValue,
chancePercent: enquiry.chancePercent,
isHotProject: enquiry.chancePercent >= 70,
items: [{ id: `${nextId}-item-1`, topic: 'Scope', description: enquiry.requirementSummary, quantity: 1, unit: 'lot', unitPrice: enquiry.expectedValue, amount: enquiry.expectedValue }],
items: [
{
id: `${nextId}-item-1`,
topic: 'Scope',
description: enquiry.requirementSummary,
quantity: 1,
unit: 'lot',
unitPrice: enquiry.expectedValue,
amount: enquiry.expectedValue
}
],
topics: [
{ id: `${nextId}-topic-1`, type: 'scope', label: 'Scope', items: [enquiry.requirementSummary] },
{ id: `${nextId}-topic-2`, type: 'exclusion', label: 'Exclusion', items: ['To be confirmed'] },
{
id: `${nextId}-topic-1`,
type: 'scope',
label: 'Scope',
items: [enquiry.requirementSummary]
},
{
id: `${nextId}-topic-2`,
type: 'exclusion',
label: 'Exclusion',
items: ['To be confirmed']
},
{ id: `${nextId}-topic-3`, type: 'payment', label: 'Payment', items: ['Pending setup'] }
],
followUps: [],
@@ -357,7 +468,13 @@ export async function submitQuotationApproval(quotationId: string) {
quotation.approvalSteps.length > 0
? quotation.approvalSteps
: [
{ id: `${quotationId}-ap-1`, level: 1, approverName: 'Sales Director', approverPosition: 'Director', status: 'pending' }
{
id: `${quotationId}-ap-1`,
level: 1,
approverName: 'Sales Director',
approverPosition: 'Director',
status: 'pending'
}
]
}
: quotation
@@ -368,11 +485,11 @@ export async function approveQuotation(payload: ApprovalActionPayload) {
crmState.quotations = crmState.quotations.map((quotation) => {
if (quotation.id !== payload.quotationId) return quotation;
const nextSteps = quotation.approvalSteps.map((step, index) =>
const nextSteps: typeof quotation.approvalSteps = quotation.approvalSteps.map((step, index) =>
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
? {
...step,
status: 'approved',
status: 'approved' as const,
actedAt: '2026-06-11T11:00:00.000Z',
comment: payload.comment
}
@@ -384,7 +501,9 @@ export async function approveQuotation(payload: ApprovalActionPayload) {
...quotation,
approvalSteps: nextSteps,
status: hasPending ? 'pending_approval' : 'approved',
approvedPdfUrl: hasPending ? quotation.approvedPdfUrl : `/mock/${quotation.code.toLowerCase()}.pdf`
approvedPdfUrl: hasPending
? quotation.approvedPdfUrl
: `/mock/${quotation.code.toLowerCase()}.pdf`
};
});
@@ -398,12 +517,12 @@ export async function rejectQuotation(payload: ApprovalActionPayload) {
quotation.id === payload.quotationId
? {
...quotation,
status: 'rejected',
status: 'rejected' as const,
approvalSteps: quotation.approvalSteps.map((step, index) =>
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
? {
...step,
status: 'rejected',
status: 'rejected' as const,
actedAt: '2026-06-11T11:30:00.000Z',
comment: payload.comment
}

View File

@@ -44,7 +44,11 @@ function DecisionDialog({
<DialogTitle>{label}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Input value={comment} onChange={(event) => setComment(event.target.value)} placeholder='Comment' />
<Input
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder='Comment'
/>
<DialogFooter>
<Button isLoading={isPending} onClick={() => onConfirm(comment)}>
Confirm

View File

@@ -1,7 +1,18 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from 'recharts';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
XAxis,
YAxis
} from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
import { crmDashboardQueryOptions } from '../api/queries';
@@ -58,11 +69,25 @@ export function CrmDashboardPage() {
<CardContent>
<ChartContainer config={chartConfig}>
<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) => (
<Cell
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>
@@ -127,8 +152,20 @@ export function CrmDashboardPage() {
<XAxis dataKey='label' tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area 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} />
<Area
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>
</ChartContainer>
</CardContent>
@@ -136,7 +173,11 @@ export function CrmDashboardPage() {
</div>
<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'>
{data.pendingApprovals.map((item) => (
<div key={item.id} className='rounded-lg border p-3'>
@@ -144,7 +185,9 @@ export function CrmDashboardPage() {
<p className='font-medium'>{item.quotationCode}</p>
<QuotationStatusBadge status='pending_approval' />
</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>
</div>
))}
@@ -155,7 +198,11 @@ export function CrmDashboardPage() {
<RelatedQuotationList quotations={data.hotQuotations} />
</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'>
{data.dueFollowUps.map((item) => (
<div key={item.id} className='rounded-lg border p-3'>

View File

@@ -133,10 +133,7 @@ export function CustomerDetailPage({ id }: { id: string }) {
</div>
<div className='mt-4 space-y-3'>
{data.shares.length === 0 ? (
<EmptyState
title='ยังไม่มีการแชร์ contact'
description='เริ่มจากกรอกชื่อผู้ใช้ด้านบน'
/>
<EmptyState title='ยังไม่มีการแชร์ contact' description='เริ่มจากกรอกชื่อผู้ใช้ด้านบน' />
) : (
data.shares.map((share) => (
<div

View File

@@ -173,24 +173,14 @@ export function CustomerFormSheet({
/>
</div>
<FormTextField name='province' label='Province' required placeholder='Bangkok' />
<FormTextField
name='district'
label='District'
required
placeholder='Chatuchak'
/>
<FormTextField name='district' label='District' required placeholder='Chatuchak' />
<FormTextField
name='subDistrict'
label='Sub District'
required
placeholder='Chom Phon'
/>
<FormTextField
name='postalCode'
label='Postal Code'
required
placeholder='10900'
/>
<FormTextField name='postalCode' label='Postal Code' required placeholder='10900' />
</form.Form>
</form.AppForm>
</div>

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

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

View File

@@ -211,9 +211,7 @@ export function QuotationDetailPage({ id }: { id: string }) {
<span className='text-muted-foreground text-sm'>
Revision R{String(data.quotation.revision).padStart(2, '0')}
</span>
<span className='text-sm font-medium'>
{formatCurrency(data.quotation.totalAmount)}
</span>
<span className='text-sm font-medium'>{formatCurrency(data.quotation.totalAmount)}</span>
</div>
<div className='mt-4'>
<InfoGrid

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

View File

@@ -8,7 +8,10 @@ export function MasterOptionsPage() {
const { data } = useSuspenseQuery(crmReferenceQueryOptions());
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'>
{data.masterOptions.map((item) => (
<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 className='flex items-center justify-between gap-3'>
<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 className='text-muted-foreground text-sm'>{item.documentType}</p>
</div>

View File

@@ -86,7 +86,11 @@ export function BranchBadge({ branch }: { branch: CrmBranch }) {
export function CustomerStatusBadge({ status }: { status: Customer['customerStatus'] }) {
return (
<Badge variant={status === 'inactive' ? 'destructive' : status === 'prospect' ? 'outline' : 'secondary'}>
<Badge
variant={
status === 'inactive' ? 'destructive' : status === 'prospect' ? 'outline' : 'secondary'
}
>
{getCustomerStatusLabel(status)}
</Badge>
);
@@ -132,7 +136,14 @@ export function InfoGrid({
cols?: 2 | 3 | 4;
}) {
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) => (
<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>
@@ -199,7 +210,10 @@ export function CustomerRoleCards({
const contact = contacts.find((item) => item.id === role.contactId);
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'>
<p className='text-sm font-semibold capitalize'>{role.role}</p>
<Badge variant='outline'>{customer?.code}</Badge>
@@ -226,7 +240,10 @@ export function QuotationPreviewPanel({
approvalSteps: ApprovalStep[];
}) {
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='flex flex-wrap items-start justify-between gap-4 border-b pb-4'>
<div>
@@ -235,9 +252,16 @@ export function QuotationPreviewPanel({
<p className='text-muted-foreground text-sm'>{quotation.project}</p>
</div>
<div className='space-y-1 text-sm'>
<p><span className='text-muted-foreground'>quotation_date:</span> {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>
<p>
<span className='text-muted-foreground'>quotation_date:</span>{' '}
{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>
@@ -262,7 +286,10 @@ export function QuotationPreviewPanel({
<p className='text-sm font-medium'>item_topic</p>
<div className='mt-2 space-y-2'>
{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>
<p className='font-medium'>{item.description}</p>
<p className='text-muted-foreground'>
@@ -279,7 +306,9 @@ export function QuotationPreviewPanel({
<ul className='text-muted-foreground mt-1 space-y-1'>
{quotation.topics
.find((topic) => topic.type === 'exclusion')
?.items.map((item) => <li key={item}>- {item}</li>)}
?.items.map((item) => (
<li key={item}>- {item}</li>
))}
</ul>
</div>
<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 (
<div className='space-y-3'>
{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 className='flex items-center gap-2'>
<p className='font-medium'>{quotation.code}</p>
@@ -317,7 +349,7 @@ export function RelatedQuotationList({ quotations }: { quotations: Quotation[] }
<div className='flex items-center gap-3'>
<p className='text-sm font-medium'>{formatCurrency(quotation.totalAmount)}</p>
<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' />
</Link>
@@ -347,11 +379,7 @@ export function AuditTimeline({ events }: { events: AuditEvent[] }) {
actorName: event.actorName,
timestamp: event.createdAt,
tone:
event.action === 'APPROVE'
? 'success'
: event.action === 'REJECT'
? 'danger'
: 'default'
event.action === 'APPROVE' ? 'success' : event.action === 'REJECT' ? 'danger' : 'default'
}))}
/>
);

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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' }
]
}
]
};

View File

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

View File

@@ -11,6 +11,15 @@ export const searchParams = {
name: parseAsString,
gender: parseAsString,
category: parseAsString,
customerStatus: parseAsString,
branch: parseAsString,
productType: parseAsString,
salesman: parseAsString,
dateFrom: parseAsString,
dateTo: parseAsString,
quotationType: parseAsString,
hot: parseAsString,
view: parseAsString,
status: parseAsString,
assetType: parseAsString,
role: parseAsString,