task-fic-display
This commit is contained in:
47
docs/operations/system-bootstrap.md
Normal file
47
docs/operations/system-bootstrap.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# System Bootstrap
|
||||||
|
|
||||||
|
## Fresh local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
ALLOW_DB_RESET=true npm run db:fresh
|
||||||
|
npm exec tsc --noEmit
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fresh UAT or demo data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ALLOW_DB_RESET=true npm run db:fresh:uat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Existing database upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
npm run seed:system
|
||||||
|
```
|
||||||
|
|
||||||
|
## PDF template reseed only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run seed:pdf-template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm exec tsc --noEmit
|
||||||
|
npm run build
|
||||||
|
npm run verify:encoding
|
||||||
|
npm run verify:crm-access
|
||||||
|
npm run audit:pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Script guide
|
||||||
|
|
||||||
|
- `db:*` = database lifecycle
|
||||||
|
- `seed:*` = data bootstrap
|
||||||
|
- `verify:*` = validation
|
||||||
|
- `audit:*` = diagnostics
|
||||||
|
- `legacy:*` = historical task-specific tools
|
||||||
20
package.json
20
package.json
@@ -13,16 +13,30 @@
|
|||||||
"format:check": "oxfmt --check .",
|
"format:check": "oxfmt --check .",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"gen": "npm run verify:encoding && npx drizzle-kit generate",
|
"gen": "npm run verify:encoding && npx drizzle-kit generate",
|
||||||
"migrate": "npx drizzle-kit migrate",
|
"db:generate": "npx drizzle-kit generate",
|
||||||
"studio": "npx drizzle-kit studio",
|
"db:migrate": "npx drizzle-kit migrate",
|
||||||
|
"db:studio": "npx drizzle-kit studio",
|
||||||
|
"db:reset": "node --experimental-strip-types scripts/db/reset-database.ts",
|
||||||
|
"db:setup": "npm run db:migrate && npm run seed:system",
|
||||||
|
"db:setup:uat": "npm run db:setup && npm run seed:uat",
|
||||||
|
"db:fresh": "npm run db:reset && npm run db:migrate && npm run seed:system",
|
||||||
|
"db:fresh:uat": "npm run db:fresh && npm run seed:uat",
|
||||||
|
"migrate": "npm run db:migrate",
|
||||||
|
"studio": "npm run db:studio",
|
||||||
"seed:super-admin": "node scripts/seed-super-admin.js",
|
"seed:super-admin": "node scripts/seed-super-admin.js",
|
||||||
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
|
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
|
||||||
"seed:crm-uat": "node --experimental-strip-types src/db/seeds/crm-uat.seed.ts",
|
"seed:crm-uat": "node --experimental-strip-types src/db/seeds/crm-uat.seed.ts",
|
||||||
"seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
|
"seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
|
||||||
|
"seed:system": "node --experimental-strip-types src/db/seeds/system.seed.ts",
|
||||||
|
"seed:uat": "node --experimental-strip-types src/db/seeds/uat.seed.ts",
|
||||||
|
"seed:pdf-template": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
|
||||||
"migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts",
|
"migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts",
|
||||||
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
||||||
|
"legacy:seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
||||||
"verify:task-h1": "node scripts/verify-task-h1.js",
|
"verify:task-h1": "node scripts/verify-task-h1.js",
|
||||||
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
||||||
|
"legacy:verify:task-h1": "node scripts/verify-task-h1.js",
|
||||||
|
"legacy:verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
||||||
"verify:crm-access": "node --experimental-strip-types scripts/security/verify-crm-access.ts",
|
"verify:crm-access": "node --experimental-strip-types scripts/security/verify-crm-access.ts",
|
||||||
"audit:pdf:inventory": "node --experimental-strip-types scripts/audit-pdf-template-inventory.ts",
|
"audit:pdf:inventory": "node --experimental-strip-types scripts/audit-pdf-template-inventory.ts",
|
||||||
"audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts",
|
"audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts",
|
||||||
@@ -35,7 +49,7 @@
|
|||||||
"audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts",
|
"audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts",
|
||||||
"audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts",
|
"audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts",
|
||||||
"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity",
|
"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity",
|
||||||
"setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
|
"setup:db": "npm run db:setup"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1069.0",
|
"@aws-sdk/client-s3": "^3.1069.0",
|
||||||
|
|||||||
772
plans/task-fix-display.md
Normal file
772
plans/task-fix-display.md
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
# Task Foundation Cleanup: Admin Display Names, MS Option CRUD, Seed & Database Script Standardization
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Planned
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Clean up foundational admin experience and system bootstrap workflow before continuing feature development.
|
||||||
|
|
||||||
|
This task focuses on:
|
||||||
|
|
||||||
|
* displaying human-readable names instead of raw IDs
|
||||||
|
* adding CRUD management for `ms_options`
|
||||||
|
* consolidating seed scripts
|
||||||
|
* creating a clear system bootstrap script
|
||||||
|
* creating a reset database script for development
|
||||||
|
* auditing old seed/verify/audit scripts
|
||||||
|
* documenting what system admin must run when starting from a fresh database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Current system still exposes raw IDs in some UI/API responses, such as:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
createdBy
|
||||||
|
updatedBy
|
||||||
|
assignedBy
|
||||||
|
branchId
|
||||||
|
salesmanId
|
||||||
|
assignedToUserId
|
||||||
|
closedByUserId
|
||||||
|
generatedBy
|
||||||
|
uploadedBy
|
||||||
|
approvedBy
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes admin screens, CRM pages, approval history, audit views, and document settings difficult to read.
|
||||||
|
|
||||||
|
Current seed/setup scripts are also fragmented:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"seed:super-admin"
|
||||||
|
"seed:foundation"
|
||||||
|
"seed:crm-uat"
|
||||||
|
"seed:pdf-template-version"
|
||||||
|
"migrate:membership-business-roles"
|
||||||
|
"seed:task-h1-fixture"
|
||||||
|
"verify:task-h1"
|
||||||
|
"verify:task-h3"
|
||||||
|
"verify:crm-access"
|
||||||
|
"audit:pdf:*"
|
||||||
|
"setup:db"
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no clear single command for:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Fresh system bootstrap
|
||||||
|
Development reset
|
||||||
|
UAT seed
|
||||||
|
PDF template reseed
|
||||||
|
Verification
|
||||||
|
Audit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope 1: Human-Readable Display Names
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Replace raw ID display with readable names across active UI and API DTOs.
|
||||||
|
|
||||||
|
## Required Display Mapping
|
||||||
|
|
||||||
|
### User-related fields
|
||||||
|
|
||||||
|
Fields that should expose display names:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
createdBy
|
||||||
|
updatedBy
|
||||||
|
assignedBy
|
||||||
|
assignedToUserId
|
||||||
|
assignedSalesOwnerId
|
||||||
|
salesmanId
|
||||||
|
closedByUserId
|
||||||
|
approvedBy
|
||||||
|
requestedBy
|
||||||
|
actedBy
|
||||||
|
generatedBy
|
||||||
|
uploadedBy
|
||||||
|
voidedBy
|
||||||
|
lockedBy
|
||||||
|
```
|
||||||
|
|
||||||
|
Add readable fields where applicable:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
createdByName
|
||||||
|
updatedByName
|
||||||
|
assignedByName
|
||||||
|
assignedToUserName
|
||||||
|
assignedSalesOwnerName
|
||||||
|
salesmanName
|
||||||
|
closedByName
|
||||||
|
approvedByName
|
||||||
|
requestedByName
|
||||||
|
actedByName
|
||||||
|
generatedByName
|
||||||
|
uploadedByName
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Keep raw IDs for system integrity.
|
||||||
|
* Add display fields for UI.
|
||||||
|
* Do not remove ID fields from API unless all consumers are migrated.
|
||||||
|
* Use fallback:
|
||||||
|
|
||||||
|
* user name
|
||||||
|
* email
|
||||||
|
* ID as last resort
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Branch-related fields
|
||||||
|
|
||||||
|
Fields that should expose readable branch data:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
branchId
|
||||||
|
```
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
branchName
|
||||||
|
branchCode
|
||||||
|
```
|
||||||
|
|
||||||
|
If branch master is not a dedicated table and comes from `ms_options`, resolve from master option category.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option-related fields
|
||||||
|
|
||||||
|
Fields that should expose labels:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
productType
|
||||||
|
status
|
||||||
|
priority
|
||||||
|
stage
|
||||||
|
outcomeStatus
|
||||||
|
lostReason
|
||||||
|
cancelReason
|
||||||
|
noQuotationReason
|
||||||
|
customerType
|
||||||
|
customerStatus
|
||||||
|
leadChannel
|
||||||
|
quotationType
|
||||||
|
discountType
|
||||||
|
currency
|
||||||
|
unit
|
||||||
|
taxRate
|
||||||
|
```
|
||||||
|
|
||||||
|
Add display labels:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
productTypeLabel
|
||||||
|
statusLabel
|
||||||
|
priorityLabel
|
||||||
|
stageLabel
|
||||||
|
outcomeStatusLabel
|
||||||
|
lostReasonLabel
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* Use `ms_options` as source of display labels.
|
||||||
|
* Do not hardcode labels in UI.
|
||||||
|
* Support fallback to raw code if option label is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Areas
|
||||||
|
|
||||||
|
Review and update active UI/API areas:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/leads/**
|
||||||
|
src/features/crm/opportunities/**
|
||||||
|
src/features/crm/quotations/**
|
||||||
|
src/features/crm/customers/**
|
||||||
|
src/features/crm/approval/**
|
||||||
|
src/features/foundation/approval/**
|
||||||
|
src/features/foundation/notifications/**
|
||||||
|
src/features/crm/dashboard/**
|
||||||
|
src/features/crm/document-templates/**
|
||||||
|
```
|
||||||
|
|
||||||
|
High-priority pages:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Lead list/detail
|
||||||
|
Opportunity list/detail
|
||||||
|
Quotation list/detail
|
||||||
|
Approval history
|
||||||
|
Approval settings
|
||||||
|
Notification inbox
|
||||||
|
Template settings
|
||||||
|
Dashboard widgets
|
||||||
|
Audit-safe activity views
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope 2: Create Shared Display Resolver
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Avoid repeating joins and label mapping everywhere.
|
||||||
|
|
||||||
|
Create shared foundation utilities:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/foundation/display/server/user-display-resolver.ts
|
||||||
|
src/features/foundation/display/server/option-display-resolver.ts
|
||||||
|
src/features/foundation/display/server/branch-display-resolver.ts
|
||||||
|
src/features/foundation/display/server/display-resolver.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended APIs:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
resolveUserDisplays(ctx, userIds: string[])
|
||||||
|
resolveOptionLabels(ctx, category: string, codes: string[])
|
||||||
|
resolveBranchDisplays(ctx, branchIds: string[])
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* batch load to avoid N+1 queries
|
||||||
|
* organization scoped
|
||||||
|
* safe fallback
|
||||||
|
* reusable by CRM services
|
||||||
|
* not tied to one feature
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope 3: MS Option CRUD
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Add admin management for `ms_options`.
|
||||||
|
|
||||||
|
## Required APIs
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/foundation/ms-options
|
||||||
|
POST /api/foundation/ms-options
|
||||||
|
GET /api/foundation/ms-options/[id]
|
||||||
|
PATCH /api/foundation/ms-options/[id]
|
||||||
|
DELETE /api/foundation/ms-options/[id]
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional category-specific API:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/foundation/ms-options/categories
|
||||||
|
GET /api/foundation/ms-options?category=crm_opportunity_stage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required UI
|
||||||
|
|
||||||
|
Create settings page:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/dashboard/settings/master-options
|
||||||
|
```
|
||||||
|
|
||||||
|
or if CRM-scoped:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/dashboard/crm/settings/master-options
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended UI:
|
||||||
|
|
||||||
|
* category filter
|
||||||
|
* option table
|
||||||
|
* create option sheet
|
||||||
|
* edit option sheet
|
||||||
|
* soft delete / deactivate
|
||||||
|
* active toggle
|
||||||
|
* sort order
|
||||||
|
* metadata JSON viewer/editor if needed
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
category
|
||||||
|
code
|
||||||
|
label
|
||||||
|
value
|
||||||
|
parentId
|
||||||
|
sortOrder
|
||||||
|
isActive
|
||||||
|
metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* `category + code + organizationId` must remain unique
|
||||||
|
* cannot hard delete; use soft delete
|
||||||
|
* inactive options should not appear in normal dropdowns
|
||||||
|
* admin can view inactive options
|
||||||
|
* no duplicate code in same category
|
||||||
|
* code should be normalized to snake_case or documented format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope 4: Seed Script Consolidation
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create a clear, predictable seed hierarchy.
|
||||||
|
|
||||||
|
## Recommended Seed Layers
|
||||||
|
|
||||||
|
### 1. System seed
|
||||||
|
|
||||||
|
Required for system to boot:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
organization
|
||||||
|
super admin
|
||||||
|
admin membership
|
||||||
|
base roles/permissions
|
||||||
|
foundation master options
|
||||||
|
document sequences
|
||||||
|
approval default workflow
|
||||||
|
approval matrix default
|
||||||
|
notification templates
|
||||||
|
quotation PDF template metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"seed:system": "node --experimental-strip-types src/db/seeds/system.seed.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. UAT seed
|
||||||
|
|
||||||
|
Demo/testing data only:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
sample customers
|
||||||
|
sample contacts
|
||||||
|
sample leads
|
||||||
|
sample opportunities
|
||||||
|
sample quotations
|
||||||
|
sample approvals
|
||||||
|
sample notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"seed:uat": "node --experimental-strip-types src/db/seeds/uat.seed.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. PDF template seed
|
||||||
|
|
||||||
|
Template-only reseed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"seed:pdf-template": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Verification scripts
|
||||||
|
|
||||||
|
Keep as verification, not seed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"verify:encoding"
|
||||||
|
"verify:crm-access"
|
||||||
|
"verify:pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Standard Commands
|
||||||
|
|
||||||
|
Recommended `package.json` script structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
|
||||||
|
"db:reset": "node --experimental-strip-types scripts/db/reset-database.ts",
|
||||||
|
"db:setup": "npm run db:migrate && npm run seed:system",
|
||||||
|
"db:setup:uat": "npm run db:setup && npm run seed:uat",
|
||||||
|
"db:fresh": "npm run db:reset && npm run db:migrate && npm run seed:system",
|
||||||
|
"db:fresh:uat": "npm run db:reset && npm run db:migrate && npm run seed:system && npm run seed:uat",
|
||||||
|
|
||||||
|
"seed:system": "node --experimental-strip-types src/db/seeds/system.seed.ts",
|
||||||
|
"seed:uat": "node --experimental-strip-types src/db/seeds/uat.seed.ts",
|
||||||
|
"seed:pdf-template": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
|
||||||
|
|
||||||
|
"verify:encoding": "node scripts/verify-encoding.mjs",
|
||||||
|
"verify:crm-access": "node --experimental-strip-types scripts/security/verify-crm-access.ts",
|
||||||
|
"verify:pdf": "npm run audit:pdf",
|
||||||
|
|
||||||
|
"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope 5: Reset Database Script
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create a controlled development reset script.
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
scripts/db/reset-database.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* connect to configured database
|
||||||
|
* confirm environment is not production
|
||||||
|
* drop public schema
|
||||||
|
* recreate public schema
|
||||||
|
* drop drizzle schema if present
|
||||||
|
* exit cleanly
|
||||||
|
|
||||||
|
Required safety guard:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
NODE_ENV must not be production
|
||||||
|
DATABASE_URL must not include production host/name
|
||||||
|
ALLOW_DB_RESET=true required
|
||||||
|
```
|
||||||
|
|
||||||
|
Example command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ALLOW_DB_RESET=true npm run db:reset
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP SCHEMA IF EXISTS public CASCADE;
|
||||||
|
CREATE SCHEMA public;
|
||||||
|
|
||||||
|
DROP SCHEMA IF EXISTS drizzle CASCADE;
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
* never run reset without explicit env flag
|
||||||
|
* log target database before reset
|
||||||
|
* fail fast if DATABASE_URL missing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope 6: Existing Script Audit
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Classify existing scripts into keep, rename, merge, or remove.
|
||||||
|
|
||||||
|
Current scripts to audit:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"seed:super-admin": "node scripts/seed-super-admin.js",
|
||||||
|
"seed:foundation": "node --experimental-strip-types src/db/seeds/foundation.seed.ts",
|
||||||
|
"seed:crm-uat": "node --experimental-strip-types src/db/seeds/crm-uat.seed.ts",
|
||||||
|
"seed:pdf-template-version": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts",
|
||||||
|
"migrate:membership-business-roles": "node --experimental-strip-types scripts/migrate-membership-business-roles.ts",
|
||||||
|
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
||||||
|
"verify:task-h1": "node scripts/verify-task-h1.js",
|
||||||
|
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
||||||
|
"verify:crm-access": "node --experimental-strip-types scripts/security/verify-crm-access.ts",
|
||||||
|
"audit:pdf:inventory": "node --experimental-strip-types scripts/audit-pdf-template-inventory.ts",
|
||||||
|
"audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts",
|
||||||
|
"audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts",
|
||||||
|
"audit:pdf:report": "node --experimental-strip-types scripts/generate-pdf-audit-report.ts",
|
||||||
|
"verify:encoding": "node scripts/verify-encoding.mjs",
|
||||||
|
"audit:pdf:versions": "node --experimental-strip-types scripts/audit-template-version-integrity.ts",
|
||||||
|
"audit:pdf:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts",
|
||||||
|
"audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts",
|
||||||
|
"audit:pdf:snapshot": "node --experimental-strip-types scripts/audit-approved-snapshot-parity.ts",
|
||||||
|
"audit:pdf:integrity": "node --experimental-strip-types scripts/generate-pdf-integrity-report.ts",
|
||||||
|
"audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity",
|
||||||
|
"setup:db": "npm run migrate && npm run seed:super-admin && npm run seed:foundation"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recommended Classification
|
||||||
|
|
||||||
|
### Rename / merge
|
||||||
|
|
||||||
|
```txt
|
||||||
|
seed:super-admin
|
||||||
|
seed:foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
Merge into:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
seed:system
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rename
|
||||||
|
|
||||||
|
```txt
|
||||||
|
seed:crm-uat
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
seed:uat
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rename
|
||||||
|
|
||||||
|
```txt
|
||||||
|
seed:pdf-template-version
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
seed:pdf-template
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Keep but mark legacy/dev fixture
|
||||||
|
|
||||||
|
```txt
|
||||||
|
seed:task-h1-fixture
|
||||||
|
verify:task-h1
|
||||||
|
verify:task-h3
|
||||||
|
```
|
||||||
|
|
||||||
|
Move under:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
scripts/legacy/task-fixtures/
|
||||||
|
```
|
||||||
|
|
||||||
|
or rename:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
legacy:seed:task-h1-fixture
|
||||||
|
legacy:verify:task-h1
|
||||||
|
legacy:verify:task-h3
|
||||||
|
```
|
||||||
|
|
||||||
|
If no longer used, remove after confirming no docs/CI references them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Review migration script
|
||||||
|
|
||||||
|
```txt
|
||||||
|
migrate:membership-business-roles
|
||||||
|
```
|
||||||
|
|
||||||
|
If this was one-time historical migration, move to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
scripts/legacy/migrations/
|
||||||
|
```
|
||||||
|
|
||||||
|
and remove from common package scripts.
|
||||||
|
|
||||||
|
If still needed after reset, fold into migration or seed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Keep
|
||||||
|
|
||||||
|
```txt
|
||||||
|
verify:crm-access
|
||||||
|
verify:encoding
|
||||||
|
audit:pdf:*
|
||||||
|
audit:pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
These are still useful.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Replace
|
||||||
|
|
||||||
|
```txt
|
||||||
|
setup:db
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
db:setup
|
||||||
|
db:setup:uat
|
||||||
|
db:fresh
|
||||||
|
db:fresh:uat
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope 7: Bootstrap Documentation
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/operations/system-bootstrap.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Must explain:
|
||||||
|
|
||||||
|
## Fresh local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run db:fresh
|
||||||
|
npm exec tsc --noEmit
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fresh UAT/demo data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:fresh:uat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Existing database upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:migrate
|
||||||
|
npm run seed:system
|
||||||
|
```
|
||||||
|
|
||||||
|
## PDF template reseed only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run seed:pdf-template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm exec tsc --noEmit
|
||||||
|
npm run build
|
||||||
|
npm run verify:encoding
|
||||||
|
npm run verify:crm-access
|
||||||
|
npm run audit:pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Script guide
|
||||||
|
|
||||||
|
Explain:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
db:* = database lifecycle
|
||||||
|
seed:* = data bootstrap
|
||||||
|
verify:* = validation
|
||||||
|
audit:* = diagnostics
|
||||||
|
legacy:* = old task-specific tools
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope 8: Acceptance Criteria
|
||||||
|
|
||||||
|
## Display names
|
||||||
|
|
||||||
|
* Created by / Updated by / Assigned by display as readable names
|
||||||
|
* Branch displays as branch name/code
|
||||||
|
* Option fields display labels
|
||||||
|
* Raw IDs are not shown in normal user-facing UI unless intentionally technical
|
||||||
|
|
||||||
|
## MS option CRUD
|
||||||
|
|
||||||
|
* Admin can list options
|
||||||
|
* Admin can create option
|
||||||
|
* Admin can update option
|
||||||
|
* Admin can deactivate / soft delete option
|
||||||
|
* Dropdowns continue to use active options
|
||||||
|
* Duplicate category/code is blocked
|
||||||
|
|
||||||
|
## Seed and setup
|
||||||
|
|
||||||
|
* `seed:system` exists
|
||||||
|
* `seed:uat` exists
|
||||||
|
* `db:reset` exists with safety guard
|
||||||
|
* `db:fresh` works
|
||||||
|
* `db:fresh:uat` works
|
||||||
|
* old scripts are classified or removed
|
||||||
|
* system bootstrap docs exist
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm exec tsc --noEmit
|
||||||
|
npm run build
|
||||||
|
npm run verify:encoding
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:fresh
|
||||||
|
npm run db:fresh:uat
|
||||||
|
npm run verify:crm-access
|
||||||
|
npm run audit:pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
Task is complete when:
|
||||||
|
|
||||||
|
* active UI no longer exposes raw user/branch/option IDs where display names are expected
|
||||||
|
* `ms_options` has CRUD API and settings UI
|
||||||
|
* system seed is consolidated
|
||||||
|
* UAT seed is separated
|
||||||
|
* database reset script exists and is guarded
|
||||||
|
* package scripts are organized and understandable
|
||||||
|
* old scripts are documented, renamed, moved, or removed
|
||||||
|
* bootstrap documentation is available
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Foundation Admin UX = Improved
|
||||||
|
Master Option Management = Operational
|
||||||
|
System Bootstrap = Standardized
|
||||||
|
Database Reset = Safe and Repeatable
|
||||||
|
Script Surface = Clean and Understandable
|
||||||
|
```
|
||||||
97
scripts/db/reset-database.ts
Normal file
97
scripts/db/reset-database.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
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 assertSafeToReset(databaseUrl: string) {
|
||||||
|
if (process.env.ALLOW_DB_RESET !== 'true') {
|
||||||
|
throw new Error('ALLOW_DB_RESET=true is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((process.env.NODE_ENV ?? '').toLowerCase() === 'production') {
|
||||||
|
throw new Error('Database reset is blocked in NODE_ENV=production.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(databaseUrl);
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
const databaseName = parsed.pathname.replace(/^\//, '').toLowerCase();
|
||||||
|
|
||||||
|
const blockedHostFragments = ['prod', 'production', 'rds.amazonaws.com'];
|
||||||
|
const blockedDatabaseFragments = ['prod', 'production'];
|
||||||
|
|
||||||
|
if (blockedHostFragments.some((fragment) => host.includes(fragment))) {
|
||||||
|
throw new Error(`Database reset blocked for host ${host}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockedDatabaseFragments.some((fragment) => databaseName.includes(fragment))) {
|
||||||
|
throw new Error(`Database reset blocked for database ${databaseName}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
loadLocalEnv();
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL?.trim();
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error('DATABASE_URL is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSafeToReset(databaseUrl);
|
||||||
|
|
||||||
|
const parsed = new URL(databaseUrl);
|
||||||
|
console.log(
|
||||||
|
`[db:reset] target=${parsed.hostname}/${parsed.pathname.replace(/^\//, '')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const sql = postgres(databaseUrl, { prepare: false });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql.unsafe('DROP SCHEMA IF EXISTS public CASCADE;');
|
||||||
|
await sql.unsafe('CREATE SCHEMA public;');
|
||||||
|
await sql.unsafe('DROP SCHEMA IF EXISTS drizzle CASCADE;');
|
||||||
|
console.log('[db:reset] Database schemas recreated successfully.');
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
156
src/app/api/foundation/master-options/[id]/route.ts
Normal file
156
src/app/api/foundation/master-options/[id]/route.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
|
import {
|
||||||
|
getMasterOptionById,
|
||||||
|
softDeleteMasterOption,
|
||||||
|
updateMasterOption
|
||||||
|
} from '@/features/foundation/master-options/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
const masterOptionUpdateSchema = z.object({
|
||||||
|
category: z.string().min(1).optional(),
|
||||||
|
code: z.string().min(1).optional(),
|
||||||
|
label: z.string().min(1).optional(),
|
||||||
|
value: z.string().nullable().optional(),
|
||||||
|
parent_id: z.string().nullable().optional(),
|
||||||
|
sort_order: z.number().int().optional(),
|
||||||
|
is_active: z.boolean().optional(),
|
||||||
|
metadata: z.record(z.string(), z.unknown()).nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
function mapMasterOptionResponse(item: Awaited<ReturnType<typeof getMasterOptionById>>) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
organization_id: item.organizationId,
|
||||||
|
category: item.category,
|
||||||
|
code: item.code,
|
||||||
|
label: item.label,
|
||||||
|
value: item.value,
|
||||||
|
parent_id: item.parentId,
|
||||||
|
parent_label: item.parentLabel,
|
||||||
|
sort_order: item.sortOrder,
|
||||||
|
is_active: item.isActive,
|
||||||
|
metadata: item.metadata,
|
||||||
|
deleted_at: item.deletedAt,
|
||||||
|
created_at: item.createdAt,
|
||||||
|
updated_at: item.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmMasterOptionRead
|
||||||
|
});
|
||||||
|
const { id } = await context.params;
|
||||||
|
const item = await getMasterOptionById(id, organization.id, { includeDeleted: true });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Master option loaded successfully',
|
||||||
|
item: mapMasterOptionResponse(item)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to load master option' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmMasterOptionUpdate
|
||||||
|
});
|
||||||
|
const { id } = await context.params;
|
||||||
|
const before = await getMasterOptionById(id, organization.id, { includeDeleted: true });
|
||||||
|
const payload = masterOptionUpdateSchema.parse(await request.json());
|
||||||
|
|
||||||
|
const updated = await updateMasterOption(id, organization.id, {
|
||||||
|
category: payload.category,
|
||||||
|
code: payload.code,
|
||||||
|
label: payload.label,
|
||||||
|
value: payload.value,
|
||||||
|
parentId: payload.parent_id,
|
||||||
|
sortOrder: payload.sort_order,
|
||||||
|
isActive: payload.is_active,
|
||||||
|
metadata: payload.metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditUpdate({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'ms_option',
|
||||||
|
entityId: updated.id,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: updated
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Master option updated successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ message: error.flatten().formErrors.join(', ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to update master option' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmMasterOptionDelete
|
||||||
|
});
|
||||||
|
const { id } = await context.params;
|
||||||
|
const before = await getMasterOptionById(id, organization.id, { includeDeleted: true });
|
||||||
|
const deleted = await softDeleteMasterOption(id, organization.id);
|
||||||
|
|
||||||
|
await auditDelete({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'ms_option',
|
||||||
|
entityId: deleted.id,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: deleted
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Master option deleted successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to delete master option' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/app/api/foundation/master-options/categories/route.ts
Normal file
34
src/app/api/foundation/master-options/categories/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { listMasterOptionCategories } from '@/features/foundation/master-options/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmMasterOptionRead
|
||||||
|
});
|
||||||
|
const items = await listMasterOptionCategories(organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Master option categories loaded successfully',
|
||||||
|
items: items.map((item) => ({
|
||||||
|
category: item.category,
|
||||||
|
total_items: item.totalItems,
|
||||||
|
active_items: item.activeItems
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to load master option categories' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,26 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { listMasterOptions } from '@/features/foundation/master-options/service';
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
createMasterOption,
|
||||||
|
listMasterOptions
|
||||||
|
} from '@/features/foundation/master-options/service';
|
||||||
|
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
const masterOptionSchema = z.object({
|
||||||
try {
|
category: z.string().min(1),
|
||||||
const { organization } = await requireOrganizationAccess({
|
code: z.string().min(1),
|
||||||
permission: PERMISSIONS.organizationManage
|
label: z.string().min(1),
|
||||||
});
|
value: z.string().nullable().optional(),
|
||||||
const { searchParams } = request.nextUrl;
|
parent_id: z.string().nullable().optional(),
|
||||||
const page = Number(searchParams.get('page') ?? 1);
|
sort_order: z.number().int().optional(),
|
||||||
const limit = Number(searchParams.get('limit') ?? 10);
|
is_active: z.boolean().optional(),
|
||||||
const search = searchParams.get('search') ?? undefined;
|
metadata: z.record(z.string(), z.unknown()).nullable().optional()
|
||||||
const category = searchParams.get('category') ?? undefined;
|
});
|
||||||
const sort = searchParams.get('sort') ?? undefined;
|
|
||||||
const result = await listMasterOptions({
|
|
||||||
organizationId: organization.id,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
search,
|
|
||||||
category,
|
|
||||||
sort
|
|
||||||
});
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
return NextResponse.json({
|
function mapMasterOptionResponse(item: Awaited<ReturnType<typeof createMasterOption>>) {
|
||||||
success: true,
|
return {
|
||||||
time: new Date().toISOString(),
|
|
||||||
message: 'Master options loaded successfully',
|
|
||||||
total_items: result.totalItems,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
items: result.items.map((item) => ({
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
organization_id: item.organizationId,
|
organization_id: item.organizationId,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
@@ -42,9 +31,42 @@ export async function GET(request: NextRequest) {
|
|||||||
parent_label: item.parentLabel,
|
parent_label: item.parentLabel,
|
||||||
sort_order: item.sortOrder,
|
sort_order: item.sortOrder,
|
||||||
is_active: item.isActive,
|
is_active: item.isActive,
|
||||||
|
metadata: item.metadata,
|
||||||
deleted_at: item.deletedAt,
|
deleted_at: item.deletedAt,
|
||||||
|
created_at: item.createdAt,
|
||||||
updated_at: item.updatedAt
|
updated_at: item.updatedAt
|
||||||
}))
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmMasterOptionRead
|
||||||
|
});
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const page = Number(searchParams.get('page') ?? 1);
|
||||||
|
const limit = Number(searchParams.get('limit') ?? 10);
|
||||||
|
const search = searchParams.get('search') ?? undefined;
|
||||||
|
const category = searchParams.get('category') ?? undefined;
|
||||||
|
const sort = searchParams.get('sort') ?? undefined;
|
||||||
|
|
||||||
|
const result = await listMasterOptions({
|
||||||
|
organizationId: organization.id,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
search,
|
||||||
|
category,
|
||||||
|
sort
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Master options loaded successfully',
|
||||||
|
total_items: result.totalItems,
|
||||||
|
offset: (page - 1) * limit,
|
||||||
|
limit,
|
||||||
|
items: result.items.map(mapMasterOptionResponse)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
@@ -58,3 +80,49 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ message: 'Unable to load master options' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to load master options' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmMasterOptionCreate
|
||||||
|
});
|
||||||
|
const payload = masterOptionSchema.parse(await request.json());
|
||||||
|
const created = await createMasterOption(organization.id, {
|
||||||
|
category: payload.category,
|
||||||
|
code: payload.code,
|
||||||
|
label: payload.label,
|
||||||
|
value: payload.value,
|
||||||
|
parentId: payload.parent_id,
|
||||||
|
sortOrder: payload.sort_order,
|
||||||
|
isActive: payload.is_active,
|
||||||
|
metadata: payload.metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditCreate({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
entityType: 'ms_option',
|
||||||
|
entityId: created.id,
|
||||||
|
afterData: created
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Master option created successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ message: error.flatten().formErrors.join(', ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to create master option' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1
src/app/api/foundation/ms-options/[id]/route.ts
Normal file
1
src/app/api/foundation/ms-options/[id]/route.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DELETE, GET, PATCH } from '@/app/api/foundation/master-options/[id]/route';
|
||||||
1
src/app/api/foundation/ms-options/categories/route.ts
Normal file
1
src/app/api/foundation/ms-options/categories/route.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { GET } from '@/app/api/foundation/master-options/categories/route';
|
||||||
1
src/app/api/foundation/ms-options/route.ts
Normal file
1
src/app/api/foundation/ms-options/route.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { GET, POST } from '@/app/api/foundation/master-options/route';
|
||||||
19
src/db/seeds/system.seed.ts
Normal file
19
src/db/seeds/system.seed.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
function run(label: string, args: string[]) {
|
||||||
|
const result = spawnSync(process.execPath, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`[seed:system] ${label} failed with exit code ${result.status ?? 1}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
run('super admin seed', ['scripts/seed-super-admin.js']);
|
||||||
|
run('foundation seed', ['--experimental-strip-types', 'src/db/seeds/foundation.seed.ts']);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
18
src/db/seeds/uat.seed.ts
Normal file
18
src/db/seeds/uat.seed.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
['--experimental-strip-types', 'src/db/seeds/crm-uat.seed.ts'],
|
||||||
|
{
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`[seed:uat] failed with exit code ${result.status ?? 1}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -17,6 +17,8 @@ export interface CustomerRecord {
|
|||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
|
branchName: string | null;
|
||||||
|
branchCode: string | null;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
abbr: string | null;
|
abbr: string | null;
|
||||||
@@ -47,7 +49,9 @@ export interface CustomerRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
createdByName: string | null;
|
||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
|
updatedByName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerListItem extends CustomerRecord {
|
export interface CustomerListItem extends CustomerRecord {
|
||||||
@@ -230,4 +234,3 @@ export interface MutationSuccessResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -301,8 +301,8 @@ export function CustomerDetail({
|
|||||||
<CardDescription>ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้</CardDescription>
|
<CardDescription>ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
<FieldItem label='Created By' value={customer.createdBy} />
|
<FieldItem label='Created By' value={customer.createdByName ?? customer.createdBy} />
|
||||||
<FieldItem label='Updated By' value={customer.updatedBy} />
|
<FieldItem label='Updated By' value={customer.updatedByName ?? customer.updatedBy} />
|
||||||
<FieldItem label='Created At' value={formatDateTime(customer.createdAt)} />
|
<FieldItem label='Created At' value={formatDateTime(customer.createdAt)} />
|
||||||
<FieldItem label='Updated At' value={formatDateTime(customer.updatedAt)} />
|
<FieldItem label='Updated At' value={formatDateTime(customer.updatedAt)} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ import {
|
|||||||
getUserBranches,
|
getUserBranches,
|
||||||
} from "@/features/foundation/branch-scope/service";
|
} from "@/features/foundation/branch-scope/service";
|
||||||
import { generateNextDocumentCode } from "@/features/foundation/document-sequence/service";
|
import { generateNextDocumentCode } from "@/features/foundation/document-sequence/service";
|
||||||
|
import {
|
||||||
|
resolveBranchDisplays,
|
||||||
|
resolveUserDisplays,
|
||||||
|
} from "@/features/foundation/display/server/display-resolver";
|
||||||
import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service";
|
import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service";
|
||||||
import {
|
import {
|
||||||
canAccessContact,
|
canAccessContact,
|
||||||
@@ -64,6 +68,13 @@ type CustomerRecordSource = Omit<
|
|||||||
ownerAssignedByName?: string | null;
|
ownerAssignedByName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CustomerDisplayOptions = {
|
||||||
|
branchName?: string | null;
|
||||||
|
branchCode?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
updatedByName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
let customerSubGroupColumnAvailable: boolean | undefined;
|
let customerSubGroupColumnAvailable: boolean | undefined;
|
||||||
|
|
||||||
export type CustomerAccessContext = CrmSecurityContext;
|
export type CustomerAccessContext = CrmSecurityContext;
|
||||||
@@ -154,11 +165,16 @@ function toOptionalIsoDateTime(value: Date | string | null | undefined) {
|
|||||||
return value ? toIsoDateTime(value) : null;
|
return value ? toIsoDateTime(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord {
|
function mapCustomerRecord(
|
||||||
|
row: CustomerRecordSource,
|
||||||
|
options?: CustomerDisplayOptions,
|
||||||
|
): CustomerRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
branchId: row.branchId,
|
branchId: row.branchId,
|
||||||
|
branchName: options?.branchName ?? null,
|
||||||
|
branchCode: options?.branchCode ?? null,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
abbr: row.abbr,
|
abbr: row.abbr,
|
||||||
@@ -189,7 +205,9 @@ function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord {
|
|||||||
updatedAt: toIsoDateTime(row.updatedAt),
|
updatedAt: toIsoDateTime(row.updatedAt),
|
||||||
deletedAt: toOptionalIsoDateTime(row.deletedAt),
|
deletedAt: toOptionalIsoDateTime(row.deletedAt),
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
|
createdByName: options?.createdByName ?? null,
|
||||||
updatedBy: row.updatedBy,
|
updatedBy: row.updatedBy,
|
||||||
|
updatedByName: options?.updatedByName ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,26 +974,35 @@ export async function getCustomerDetail(
|
|||||||
organizationId,
|
organizationId,
|
||||||
accessContext,
|
accessContext,
|
||||||
);
|
);
|
||||||
const relatedUserIds = [
|
const [userDisplayMap, branchDisplayMap] = await Promise.all([
|
||||||
|
resolveUserDisplays([
|
||||||
customer.ownerUserId,
|
customer.ownerUserId,
|
||||||
customer.ownerAssignedBy,
|
customer.ownerAssignedBy,
|
||||||
].filter(Boolean) as string[];
|
customer.createdBy,
|
||||||
const userRows = relatedUserIds.length
|
customer.updatedBy,
|
||||||
? await db
|
]),
|
||||||
.select({ id: users.id, name: users.name })
|
resolveBranchDisplays({
|
||||||
.from(users)
|
organizationId,
|
||||||
.where(inArray(users.id, relatedUserIds))
|
branchIds: [customer.branchId],
|
||||||
: [];
|
}),
|
||||||
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
]);
|
||||||
|
const branchDisplay = customer.branchId
|
||||||
|
? (branchDisplayMap.get(customer.branchId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
return mapCustomerRecord({
|
return mapCustomerRecord({
|
||||||
...customer,
|
...customer,
|
||||||
ownerName: customer.ownerUserId
|
ownerName: customer.ownerUserId
|
||||||
? (userMap.get(customer.ownerUserId) ?? null)
|
? (userDisplayMap.get(customer.ownerUserId)?.displayName ?? null)
|
||||||
: null,
|
: null,
|
||||||
ownerAssignedByName: customer.ownerAssignedBy
|
ownerAssignedByName: customer.ownerAssignedBy
|
||||||
? (userMap.get(customer.ownerAssignedBy) ?? null)
|
? (userDisplayMap.get(customer.ownerAssignedBy)?.displayName ?? null)
|
||||||
: null,
|
: null,
|
||||||
|
}, {
|
||||||
|
branchName: branchDisplay?.name ?? null,
|
||||||
|
branchCode: branchDisplay?.code ?? null,
|
||||||
|
createdByName: userDisplayMap.get(customer.createdBy)?.displayName ?? null,
|
||||||
|
updatedByName: userDisplayMap.get(customer.updatedBy)?.displayName ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1615,4 +1642,3 @@ export async function resolveCustomerOptionLabel(
|
|||||||
|
|
||||||
return row?.label ?? null;
|
return row?.label ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface QuotationRecord {
|
|||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
|
branchName: string | null;
|
||||||
|
branchCode: string | null;
|
||||||
code: string;
|
code: string;
|
||||||
opportunityId: string | null;
|
opportunityId: string | null;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
@@ -91,6 +93,7 @@ export interface QuotationRecord {
|
|||||||
isHotProject: boolean;
|
isHotProject: boolean;
|
||||||
competitor: string | null;
|
competitor: string | null;
|
||||||
salesmanId: string | null;
|
salesmanId: string | null;
|
||||||
|
salesmanName: string | null;
|
||||||
isSent: boolean;
|
isSent: boolean;
|
||||||
sentAt: string | null;
|
sentAt: string | null;
|
||||||
sentVia: string | null;
|
sentVia: string | null;
|
||||||
@@ -109,7 +112,9 @@ export interface QuotationRecord {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
createdByName: string | null;
|
||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
|
updatedByName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotationListItem extends QuotationRecord {
|
export interface QuotationListItem extends QuotationRecord {
|
||||||
|
|||||||
@@ -1716,8 +1716,8 @@ export function QuotationDetail({
|
|||||||
<CardDescription>Operational metadata for this quotation row.</CardDescription>
|
<CardDescription>Operational metadata for this quotation row.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
<FieldItem label='Created By' value={quotation.createdBy} />
|
<FieldItem label='Created By' value={quotation.createdByName ?? quotation.createdBy} />
|
||||||
<FieldItem label='Updated By' value={quotation.updatedBy} />
|
<FieldItem label='Updated By' value={quotation.updatedByName ?? quotation.updatedBy} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Created At'
|
label='Created At'
|
||||||
value={formatDateTime(quotation.createdAt)}
|
value={formatDateTime(quotation.createdAt)}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
canAccessScopedCrmRecord
|
canAccessScopedCrmRecord
|
||||||
} from '@/features/crm/security/server/service';
|
} from '@/features/crm/security/server/service';
|
||||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
|
import {
|
||||||
|
resolveBranchDisplays,
|
||||||
|
resolveUserDisplays
|
||||||
|
} from '@/features/foundation/display/server/display-resolver';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
import { syncOpportunityPipelineStageFromQuotationStatus } from '@/features/crm/opportunities/server/service';
|
import { syncOpportunityPipelineStageFromQuotationStatus } from '@/features/crm/opportunities/server/service';
|
||||||
import {
|
import {
|
||||||
@@ -130,12 +134,21 @@ function mapApprovedArtifactSummary(
|
|||||||
|
|
||||||
function mapQuotationRecord(
|
function mapQuotationRecord(
|
||||||
row: typeof crmQuotations.$inferSelect,
|
row: typeof crmQuotations.$inferSelect,
|
||||||
options?: { approvedArtifact?: QuotationApprovedArtifactSummary | null }
|
options?: {
|
||||||
|
approvedArtifact?: QuotationApprovedArtifactSummary | null;
|
||||||
|
branchName?: string | null;
|
||||||
|
branchCode?: string | null;
|
||||||
|
salesmanName?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
updatedByName?: string | null;
|
||||||
|
}
|
||||||
): QuotationRecord {
|
): QuotationRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
branchId: row.branchId,
|
branchId: row.branchId,
|
||||||
|
branchName: options?.branchName ?? null,
|
||||||
|
branchCode: options?.branchCode ?? null,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
opportunityId: row.opportunityId,
|
opportunityId: row.opportunityId,
|
||||||
customerId: row.customerId,
|
customerId: row.customerId,
|
||||||
@@ -164,6 +177,7 @@ function mapQuotationRecord(
|
|||||||
isHotProject: row.isHotProject,
|
isHotProject: row.isHotProject,
|
||||||
competitor: row.competitor,
|
competitor: row.competitor,
|
||||||
salesmanId: row.salesmanId,
|
salesmanId: row.salesmanId,
|
||||||
|
salesmanName: options?.salesmanName ?? null,
|
||||||
isSent: row.isSent,
|
isSent: row.isSent,
|
||||||
sentAt: row.sentAt?.toISOString() ?? null,
|
sentAt: row.sentAt?.toISOString() ?? null,
|
||||||
sentVia: row.sentVia,
|
sentVia: row.sentVia,
|
||||||
@@ -185,7 +199,9 @@ function mapQuotationRecord(
|
|||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy
|
createdByName: options?.createdByName ?? null,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
updatedByName: options?.updatedByName ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,6 +1227,27 @@ export async function getQuotationDetail(
|
|||||||
accessContext?: QuotationAccessContext
|
accessContext?: QuotationAccessContext
|
||||||
): Promise<QuotationRecord> {
|
): Promise<QuotationRecord> {
|
||||||
const quotation = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
const quotation = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||||
|
const [userDisplayMap, branchDisplayMap] = await Promise.all([
|
||||||
|
resolveUserDisplays([
|
||||||
|
quotation.salesmanId,
|
||||||
|
quotation.createdBy,
|
||||||
|
quotation.updatedBy
|
||||||
|
]),
|
||||||
|
resolveBranchDisplays({
|
||||||
|
organizationId,
|
||||||
|
branchIds: [quotation.branchId]
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
const branchDisplay = quotation.branchId ? (branchDisplayMap.get(quotation.branchId) ?? null) : null;
|
||||||
|
const baseDisplayOptions = {
|
||||||
|
branchName: branchDisplay?.name ?? null,
|
||||||
|
branchCode: branchDisplay?.code ?? null,
|
||||||
|
salesmanName: quotation.salesmanId
|
||||||
|
? (userDisplayMap.get(quotation.salesmanId)?.displayName ?? null)
|
||||||
|
: null,
|
||||||
|
createdByName: userDisplayMap.get(quotation.createdBy)?.displayName ?? null,
|
||||||
|
updatedByName: userDisplayMap.get(quotation.updatedBy)?.displayName ?? null
|
||||||
|
};
|
||||||
const approvedArtifactId =
|
const approvedArtifactId =
|
||||||
quotation.approvedArtifactId ??
|
quotation.approvedArtifactId ??
|
||||||
(quotation.approvedPdfUrl?.startsWith('artifact:')
|
(quotation.approvedPdfUrl?.startsWith('artifact:')
|
||||||
@@ -1218,7 +1255,7 @@ export async function getQuotationDetail(
|
|||||||
: null);
|
: null);
|
||||||
|
|
||||||
if (!approvedArtifactId) {
|
if (!approvedArtifactId) {
|
||||||
return mapQuotationRecord(quotation);
|
return mapQuotationRecord(quotation, baseDisplayOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [artifact] = await db
|
const [artifact] = await db
|
||||||
@@ -1237,19 +1274,15 @@ export async function getQuotationDetail(
|
|||||||
return mapQuotationRecord(quotation);
|
return mapQuotationRecord(quotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
const relatedUserIds = [artifact.generatedBy, artifact.lockedBy].filter(Boolean) as string[];
|
const artifactActorMap = await resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]);
|
||||||
const actorRows = relatedUserIds.length
|
|
||||||
? await db
|
|
||||||
.select({ id: users.id, name: users.name })
|
|
||||||
.from(users)
|
|
||||||
.where(inArray(users.id, relatedUserIds))
|
|
||||||
: [];
|
|
||||||
const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));
|
|
||||||
|
|
||||||
return mapQuotationRecord(quotation, {
|
return mapQuotationRecord(quotation, {
|
||||||
|
...baseDisplayOptions,
|
||||||
approvedArtifact: mapApprovedArtifactSummary(artifact, {
|
approvedArtifact: mapApprovedArtifactSummary(artifact, {
|
||||||
generatedByName: actorMap.get(artifact.generatedBy) ?? null,
|
generatedByName: artifactActorMap.get(artifact.generatedBy)?.displayName ?? null,
|
||||||
lockedByName: artifact.lockedBy ? (actorMap.get(artifact.lockedBy) ?? null) : null
|
lockedByName: artifact.lockedBy
|
||||||
|
? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null)
|
||||||
|
: null
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { resolveOptionLabels } from './option-display-resolver';
|
||||||
|
|
||||||
|
export type BranchDisplayRecord = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
value: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveBranchDisplays(input: {
|
||||||
|
organizationId: string;
|
||||||
|
branchIds: Array<string | null | undefined>;
|
||||||
|
}) {
|
||||||
|
const optionMap = await resolveOptionLabels({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
category: 'crm_branch',
|
||||||
|
optionIds: input.branchIds
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
[...optionMap.entries()].map(([id, row]) => [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.label,
|
||||||
|
value: row.value
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { resolveBranchDisplays, type BranchDisplayRecord } from './branch-display-resolver';
|
||||||
|
export { resolveOptionLabels, type OptionDisplayRecord } from './option-display-resolver';
|
||||||
|
export { resolveUserDisplays, type UserDisplayRecord } from './user-display-resolver';
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
|
import { msOptions } from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export type OptionDisplayRecord = {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveOptionLabels(input: {
|
||||||
|
organizationId: string;
|
||||||
|
category: string;
|
||||||
|
optionIds: Array<string | null | undefined>;
|
||||||
|
}) {
|
||||||
|
const uniqueIds = [...new Set(input.optionIds.filter((value): value is string => Boolean(value)))];
|
||||||
|
|
||||||
|
if (!uniqueIds.length) {
|
||||||
|
return new Map<string, OptionDisplayRecord>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: msOptions.id,
|
||||||
|
category: msOptions.category,
|
||||||
|
code: msOptions.code,
|
||||||
|
label: msOptions.label,
|
||||||
|
value: msOptions.value
|
||||||
|
})
|
||||||
|
.from(msOptions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(msOptions.organizationId, input.organizationId),
|
||||||
|
eq(msOptions.category, input.category),
|
||||||
|
inArray(msOptions.id, uniqueIds),
|
||||||
|
isNull(msOptions.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Map(rows.map((row) => [row.id, row]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
|
import { users } from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export type UserDisplayRecord = {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveDisplayName(row: { id: string; name: string | null; email: string }) {
|
||||||
|
const name = row.name?.trim();
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = row.email.trim();
|
||||||
|
if (email) {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveUserDisplays(userIds: Array<string | null | undefined>) {
|
||||||
|
const uniqueIds = [...new Set(userIds.filter((value): value is string => Boolean(value)))];
|
||||||
|
|
||||||
|
if (!uniqueIds.length) {
|
||||||
|
return new Map<string, UserDisplayRecord>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
email: users.email
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, uniqueIds));
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [
|
||||||
|
row.id,
|
||||||
|
{
|
||||||
|
...row,
|
||||||
|
displayName: resolveDisplayName(row)
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1,59 @@
|
|||||||
export {};
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
import {
|
||||||
|
createMasterOption,
|
||||||
|
deleteMasterOption,
|
||||||
|
updateMasterOption
|
||||||
|
} from './service';
|
||||||
|
import { masterOptionKeys } from './queries';
|
||||||
|
import type { MasterOptionMutationPayload } from './types';
|
||||||
|
|
||||||
|
async function invalidateMasterOptionLists() {
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: masterOptionKeys.lists() }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: masterOptionKeys.categories() })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invalidateMasterOptionDetail(id: string) {
|
||||||
|
await getQueryClient().invalidateQueries({ queryKey: masterOptionKeys.detail(id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMasterOptionMutation = mutationOptions({
|
||||||
|
mutationFn: (data: MasterOptionMutationPayload) => createMasterOption(data),
|
||||||
|
onSettled: async (_data, error) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateMasterOptionLists();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateMasterOptionMutation = mutationOptions({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
values
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
values: Partial<MasterOptionMutationPayload>;
|
||||||
|
}) => updateMasterOption(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await Promise.all([
|
||||||
|
invalidateMasterOptionLists(),
|
||||||
|
invalidateMasterOptionDetail(variables.id)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteMasterOptionMutation = mutationOptions({
|
||||||
|
mutationFn: (id: string) => deleteMasterOption(id),
|
||||||
|
onSettled: async (_data, error, id) => {
|
||||||
|
if (!error) {
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
await invalidateMasterOptionLists();
|
||||||
|
queryClient.removeQueries({ queryKey: masterOptionKeys.detail(id) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query';
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
import { getMasterOptions } from './service';
|
import {
|
||||||
|
getMasterOptionById,
|
||||||
|
getMasterOptionCategories,
|
||||||
|
getMasterOptions
|
||||||
|
} from './service';
|
||||||
import type { MasterOptionsFilters } from './types';
|
import type { MasterOptionsFilters } from './types';
|
||||||
|
|
||||||
export const masterOptionKeys = {
|
export const masterOptionKeys = {
|
||||||
all: ['foundation', 'master-options'] as const,
|
all: ['foundation', 'ms-options'] as const,
|
||||||
list: (filters: MasterOptionsFilters) => [...masterOptionKeys.all, 'list', filters] as const
|
lists: () => [...masterOptionKeys.all, 'list'] as const,
|
||||||
|
list: (filters: MasterOptionsFilters) => [...masterOptionKeys.lists(), filters] as const,
|
||||||
|
details: () => [...masterOptionKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...masterOptionKeys.details(), id] as const,
|
||||||
|
categories: () => [...masterOptionKeys.all, 'categories'] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
export const masterOptionsQueryOptions = (filters: MasterOptionsFilters) =>
|
export const masterOptionsQueryOptions = (filters: MasterOptionsFilters) =>
|
||||||
@@ -12,3 +20,15 @@ export const masterOptionsQueryOptions = (filters: MasterOptionsFilters) =>
|
|||||||
queryKey: masterOptionKeys.list(filters),
|
queryKey: masterOptionKeys.list(filters),
|
||||||
queryFn: () => getMasterOptions(filters)
|
queryFn: () => getMasterOptions(filters)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const masterOptionByIdQueryOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: masterOptionKeys.detail(id),
|
||||||
|
queryFn: () => getMasterOptionById(id)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const masterOptionCategoriesQueryOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: masterOptionKeys.categories(),
|
||||||
|
queryFn: () => getMasterOptionCategories()
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import type { MasterOptionsFilters, MasterOptionsResponse } from './types';
|
import type {
|
||||||
|
MasterOptionCategoriesResponse,
|
||||||
|
MasterOptionDetailResponse,
|
||||||
|
MasterOptionMutationPayload,
|
||||||
|
MasterOptionsFilters,
|
||||||
|
MasterOptionsResponse,
|
||||||
|
MutationSuccessResponse
|
||||||
|
} from './types';
|
||||||
|
|
||||||
export async function getMasterOptions(
|
const BASE_PATH = '/foundation/ms-options';
|
||||||
filters: MasterOptionsFilters
|
|
||||||
): Promise<MasterOptionsResponse> {
|
export async function getMasterOptions(filters: MasterOptionsFilters): Promise<MasterOptionsResponse> {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
if (filters.page) searchParams.set('page', String(filters.page));
|
if (filters.page) searchParams.set('page', String(filters.page));
|
||||||
@@ -13,6 +20,38 @@ export async function getMasterOptions(
|
|||||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||||
|
|
||||||
const query = searchParams.toString();
|
const query = searchParams.toString();
|
||||||
|
return apiClient<MasterOptionsResponse>(`${BASE_PATH}${query ? `?${query}` : ''}`);
|
||||||
return apiClient<MasterOptionsResponse>(`/foundation/master-options${query ? `?${query}` : ''}`);
|
}
|
||||||
|
|
||||||
|
export async function getMasterOptionById(id: string): Promise<MasterOptionDetailResponse> {
|
||||||
|
return apiClient<MasterOptionDetailResponse>(`${BASE_PATH}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMasterOption(
|
||||||
|
data: MasterOptionMutationPayload
|
||||||
|
): Promise<MutationSuccessResponse> {
|
||||||
|
return apiClient<MutationSuccessResponse>(BASE_PATH, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMasterOption(
|
||||||
|
id: string,
|
||||||
|
data: Partial<MasterOptionMutationPayload>
|
||||||
|
): Promise<MutationSuccessResponse> {
|
||||||
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMasterOption(id: string): Promise<MutationSuccessResponse> {
|
||||||
|
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMasterOptionCategories(): Promise<MasterOptionCategoriesResponse> {
|
||||||
|
return apiClient<MasterOptionCategoriesResponse>(`${BASE_PATH}/categories`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export interface MasterOptionItem {
|
|||||||
parent_label: string | null;
|
parent_label: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
deleted_at: string | null;
|
deleted_at: string | null;
|
||||||
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +23,17 @@ export interface MasterOptionsFilters {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MasterOptionMutationPayload {
|
||||||
|
category: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
sort_order?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MasterOptionsResponse {
|
export interface MasterOptionsResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
@@ -30,3 +43,26 @@ export interface MasterOptionsResponse {
|
|||||||
limit: number;
|
limit: number;
|
||||||
items: MasterOptionItem[];
|
items: MasterOptionItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MasterOptionDetailResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
item: MasterOptionItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterOptionCategoriesResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
items: Array<{
|
||||||
|
category: string;
|
||||||
|
total_items: number;
|
||||||
|
active_items: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MutationSuccessResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { CRM_MASTER_OPTION_CATEGORIES } from '@/features/foundation/master-options/types';
|
||||||
|
import type { MasterOptionItem, MasterOptionMutationPayload } from '../api/types';
|
||||||
|
|
||||||
|
const EMPTY_METADATA = '{}';
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
category: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
parent_id: string;
|
||||||
|
sort_order: string;
|
||||||
|
is_active: boolean;
|
||||||
|
metadata: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildInitialState(option?: MasterOptionItem): FormState {
|
||||||
|
return {
|
||||||
|
category: option?.category ?? CRM_MASTER_OPTION_CATEGORIES[0] ?? '',
|
||||||
|
code: option?.code ?? '',
|
||||||
|
label: option?.label ?? '',
|
||||||
|
value: option?.value ?? '',
|
||||||
|
parent_id: option?.parent_id ?? '__none__',
|
||||||
|
sort_order: String(option?.sort_order ?? 0),
|
||||||
|
is_active: option?.is_active ?? true,
|
||||||
|
metadata: option?.metadata ? JSON.stringify(option.metadata, null, 2) : EMPTY_METADATA
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MasterOptionsFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
pending,
|
||||||
|
option,
|
||||||
|
availableParents
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (payload: MasterOptionMutationPayload) => Promise<void>;
|
||||||
|
pending: boolean;
|
||||||
|
option?: MasterOptionItem;
|
||||||
|
availableParents: MasterOptionItem[];
|
||||||
|
}) {
|
||||||
|
const [state, setState] = useState<FormState>(buildInitialState(option));
|
||||||
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setState(buildInitialState(option));
|
||||||
|
setJsonError(null);
|
||||||
|
}
|
||||||
|
}, [open, option]);
|
||||||
|
|
||||||
|
const parentOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
availableParents.filter(
|
||||||
|
(item) => item.category === state.category && item.id !== option?.id
|
||||||
|
),
|
||||||
|
[availableParents, option?.id, state.category]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
const metadataValue = state.metadata.trim();
|
||||||
|
const metadata =
|
||||||
|
metadataValue.length > 0 ? (JSON.parse(metadataValue) as Record<string, unknown>) : null;
|
||||||
|
|
||||||
|
setJsonError(null);
|
||||||
|
|
||||||
|
await onSubmit({
|
||||||
|
category: state.category,
|
||||||
|
code: state.code,
|
||||||
|
label: state.label,
|
||||||
|
value: state.value || null,
|
||||||
|
parent_id: state.parent_id === '__none__' ? null : state.parent_id,
|
||||||
|
sort_order: Number(state.sort_order || '0'),
|
||||||
|
is_active: state.is_active,
|
||||||
|
metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
setJsonError('Metadata must be valid JSON.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className='sm:max-w-2xl'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{option ? 'Edit master option' : 'Create master option'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Manage organization-scoped lookup values without hardcoding UI labels.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='grid gap-4 py-2'>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='master-option-category'>Category</Label>
|
||||||
|
<Select
|
||||||
|
value={state.category}
|
||||||
|
onValueChange={(value) => setState((current) => ({ ...current, category: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger id='master-option-category'>
|
||||||
|
<SelectValue placeholder='Select category' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CRM_MASTER_OPTION_CATEGORIES.map((category) => (
|
||||||
|
<SelectItem key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='master-option-code'>Code</Label>
|
||||||
|
<Input
|
||||||
|
id='master-option-code'
|
||||||
|
value={state.code}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, code: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder='snake_case_code'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='master-option-label'>Label</Label>
|
||||||
|
<Input
|
||||||
|
id='master-option-label'
|
||||||
|
value={state.label}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, label: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder='Display label'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='master-option-value'>Value</Label>
|
||||||
|
<Input
|
||||||
|
id='master-option-value'
|
||||||
|
value={state.value}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, value: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder='Optional value'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='master-option-parent'>Parent</Label>
|
||||||
|
<Select
|
||||||
|
value={state.parent_id}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setState((current) => ({ ...current, parent_id: value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id='master-option-parent'>
|
||||||
|
<SelectValue placeholder='No parent' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__none__'>No parent</SelectItem>
|
||||||
|
{parentOptions.map((parent) => (
|
||||||
|
<SelectItem key={parent.id} value={parent.id}>
|
||||||
|
{parent.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='master-option-sort-order'>Sort order</Label>
|
||||||
|
<Input
|
||||||
|
id='master-option-sort-order'
|
||||||
|
type='number'
|
||||||
|
value={state.sort_order}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, sort_order: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between rounded-lg border p-3'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='text-sm font-medium'>Active option</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
Inactive options stay available for admin review but should disappear from normal
|
||||||
|
dropdowns.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={state.is_active}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setState((current) => ({ ...current, is_active: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='master-option-metadata'>Metadata JSON</Label>
|
||||||
|
<Textarea
|
||||||
|
id='master-option-metadata'
|
||||||
|
value={state.metadata}
|
||||||
|
onChange={(event) =>
|
||||||
|
setState((current) => ({ ...current, metadata: event.target.value }))
|
||||||
|
}
|
||||||
|
className='min-h-40 font-mono text-xs'
|
||||||
|
/>
|
||||||
|
{jsonError ? <p className='text-destructive text-xs'>{jsonError}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button isLoading={pending} onClick={() => void handleSubmit()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { AlertModal } from '@/components/modal/alert-modal';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { DataTable } from '@/components/ui/table/data-table';
|
import { DataTable } from '@/components/ui/table/data-table';
|
||||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||||
import { useDataTable } from '@/hooks/use-data-table';
|
import { useDataTable } from '@/hooks/use-data-table';
|
||||||
import { getSortingStateParser } from '@/lib/parsers';
|
import { getSortingStateParser } from '@/lib/parsers';
|
||||||
|
import {
|
||||||
|
createMasterOptionMutation,
|
||||||
|
deleteMasterOptionMutation,
|
||||||
|
updateMasterOptionMutation
|
||||||
|
} from '../api/mutations';
|
||||||
import { masterOptionsQueryOptions } from '../api/queries';
|
import { masterOptionsQueryOptions } from '../api/queries';
|
||||||
|
import type { MasterOptionItem, MasterOptionMutationPayload } from '../api/types';
|
||||||
import { columns } from './columns';
|
import { columns } from './columns';
|
||||||
|
import { MasterOptionsFormDialog } from './master-options-form-dialog';
|
||||||
|
|
||||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||||
|
|
||||||
@@ -19,6 +38,9 @@ export function MasterOptionsTable() {
|
|||||||
category: parseAsString,
|
category: parseAsString,
|
||||||
sort: getSortingStateParser(columnIds).withDefault([])
|
sort: getSortingStateParser(columnIds).withDefault([])
|
||||||
});
|
});
|
||||||
|
const [editingOption, setEditingOption] = useState<MasterOptionItem | undefined>();
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<MasterOptionItem | null>(null);
|
||||||
|
|
||||||
const filters = {
|
const filters = {
|
||||||
page: params.page,
|
page: params.page,
|
||||||
@@ -27,20 +49,125 @@ export function MasterOptionsTable() {
|
|||||||
...(params.category && { category: params.category }),
|
...(params.category && { category: params.category }),
|
||||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data } = useSuspenseQuery(masterOptionsQueryOptions(filters));
|
const { data } = useSuspenseQuery(masterOptionsQueryOptions(filters));
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
...createMasterOptionMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Master option created.');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Create failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
...updateMasterOptionMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Master option updated.');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Update failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
...deleteMasterOptionMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Master option deleted.');
|
||||||
|
setDeleteTarget(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Delete failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableColumns = useMemo<ColumnDef<MasterOptionItem>[]>(
|
||||||
|
() => [
|
||||||
|
...columns,
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant='ghost' size='icon' className='h-8 w-8'>
|
||||||
|
<Icons.moreHorizontal className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='end'>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setEditingOption(row.original);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className='text-destructive'
|
||||||
|
onClick={() => setDeleteTarget(row.original)}
|
||||||
|
>
|
||||||
|
Soft delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const pageCount = Math.ceil(data.total_items / params.perPage);
|
const pageCount = Math.ceil(data.total_items / params.perPage);
|
||||||
const { table } = useDataTable({
|
const { table } = useDataTable({
|
||||||
data: data.items,
|
data: data.items,
|
||||||
columns,
|
columns: tableColumns,
|
||||||
pageCount,
|
pageCount,
|
||||||
shallow: true,
|
shallow: true,
|
||||||
debounceMs: 500
|
debounceMs: 500
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(values: MasterOptionMutationPayload) {
|
||||||
|
if (editingOption) {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: editingOption.id,
|
||||||
|
values
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMutation.mutateAsync(values);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTable table={table}>
|
<DataTable table={table}>
|
||||||
|
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||||
<DataTableToolbar table={table} />
|
<DataTableToolbar table={table} />
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingOption(undefined);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icons.add className='mr-2 h-4 w-4' />
|
||||||
|
Add Option
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<MasterOptionsFormDialog
|
||||||
|
open={isFormOpen}
|
||||||
|
onOpenChange={setIsFormOpen}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
pending={createMutation.isPending || updateMutation.isPending}
|
||||||
|
option={editingOption}
|
||||||
|
availableParents={data.items}
|
||||||
|
/>
|
||||||
|
<AlertModal
|
||||||
|
isOpen={Boolean(deleteTarget)}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) {
|
||||||
|
deleteMutation.mutate(deleteTarget.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,47 @@
|
|||||||
import { and, asc, count, desc, eq, inArray, ilike, isNull, or, type SQL } from 'drizzle-orm';
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
ne,
|
||||||
|
or,
|
||||||
|
type SQL
|
||||||
|
} from 'drizzle-orm';
|
||||||
import { msOptions } from '@/db/schema';
|
import { msOptions } from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
|
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
|
||||||
import type {
|
import type {
|
||||||
|
MasterOptionCategorySummary,
|
||||||
MasterOptionListFilters,
|
MasterOptionListFilters,
|
||||||
MasterOptionListResult,
|
MasterOptionListResult,
|
||||||
|
MasterOptionMutationPayload,
|
||||||
MasterOptionNode,
|
MasterOptionNode,
|
||||||
MasterOptionRecord
|
MasterOptionRecord
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
async function resolveOrganizationId(organizationId?: string) {
|
function normalizeCode(code: string): string {
|
||||||
|
return code
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s-]+/g, '_')
|
||||||
|
.replace(/[^a-z0-9_]/g, '')
|
||||||
|
.replace(/_+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOrganizationId(organizationId?: string): Promise<string> {
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
return organizationId;
|
return organizationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const organization = await getCurrentOrganization();
|
const organization = await getCurrentOrganization();
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error('Active organization is required');
|
throw new Error('Active organization required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return organization.id;
|
return organization.id;
|
||||||
@@ -31,7 +55,6 @@ function parseSort(sort?: string) {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
||||||
const [rule] = parsed;
|
const [rule] = parsed;
|
||||||
|
|
||||||
if (!rule) {
|
if (!rule) {
|
||||||
return desc(msOptions.updatedAt);
|
return desc(msOptions.updatedAt);
|
||||||
}
|
}
|
||||||
@@ -46,7 +69,6 @@ function parseSort(sort?: string) {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const column = sortMap[rule.id as keyof typeof sortMap];
|
const column = sortMap[rule.id as keyof typeof sortMap];
|
||||||
|
|
||||||
if (!column) {
|
if (!column) {
|
||||||
return desc(msOptions.updatedAt);
|
return desc(msOptions.updatedAt);
|
||||||
}
|
}
|
||||||
@@ -58,17 +80,19 @@ function parseSort(sort?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildFilters(organizationId: string, filters: MasterOptionListFilters): SQL[] {
|
function buildFilters(organizationId: string, filters: MasterOptionListFilters): SQL[] {
|
||||||
|
const search = filters.search?.trim();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
eq(msOptions.organizationId, organizationId),
|
eq(msOptions.organizationId, organizationId),
|
||||||
...(filters.category ? [eq(msOptions.category, filters.category)] : []),
|
...(filters.category ? [eq(msOptions.category, filters.category)] : []),
|
||||||
...(filters.activeOnly ? [eq(msOptions.isActive, true)] : []),
|
...(filters.activeOnly ? [eq(msOptions.isActive, true)] : []),
|
||||||
...(!filters.includeDeleted ? [isNull(msOptions.deletedAt)] : []),
|
...(!filters.includeDeleted ? [isNull(msOptions.deletedAt)] : []),
|
||||||
...(filters.search
|
...(search
|
||||||
? [
|
? [
|
||||||
or(
|
or(
|
||||||
ilike(msOptions.label, `%${filters.search}%`),
|
ilike(msOptions.label, `%${search}%`),
|
||||||
ilike(msOptions.code, `%${filters.search}%`),
|
ilike(msOptions.code, `%${search}%`),
|
||||||
ilike(msOptions.category, `%${filters.search}%`)
|
ilike(msOptions.category, `%${search}%`)
|
||||||
)!
|
)!
|
||||||
]
|
]
|
||||||
: [])
|
: [])
|
||||||
@@ -85,21 +109,133 @@ function mapMasterOption(
|
|||||||
category: row.category,
|
category: row.category,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
label: row.label,
|
label: row.label,
|
||||||
value: row.value,
|
value: row.value ?? null,
|
||||||
parentId: row.parentId,
|
parentId: row.parentId ?? null,
|
||||||
parentLabel,
|
parentLabel,
|
||||||
sortOrder: row.sortOrder,
|
sortOrder: row.sortOrder,
|
||||||
isActive: row.isActive,
|
isActive: row.isActive,
|
||||||
metadata:
|
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||||
row.metadata && typeof row.metadata === 'object'
|
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||||
? (row.metadata as Record<string, unknown>)
|
|
||||||
: null,
|
|
||||||
deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString()
|
updatedAt: row.updatedAt.toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getParentLabelMap(
|
||||||
|
organizationId: string,
|
||||||
|
parentIds: string[]
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
if (parentIds.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentRows = await db
|
||||||
|
.select({
|
||||||
|
id: msOptions.id,
|
||||||
|
label: msOptions.label
|
||||||
|
})
|
||||||
|
.from(msOptions)
|
||||||
|
.where(and(eq(msOptions.organizationId, organizationId), inArray(msOptions.id, parentIds)));
|
||||||
|
|
||||||
|
return new Map(parentRows.map((row) => [row.id, row.label]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertOptionBelongsToOrganization(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
options?: { includeDeleted?: boolean }
|
||||||
|
): Promise<typeof msOptions.$inferSelect> {
|
||||||
|
const filters: SQL[] = [eq(msOptions.id, id), eq(msOptions.organizationId, organizationId)];
|
||||||
|
if (!options?.includeDeleted) {
|
||||||
|
filters.push(isNull(msOptions.deletedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(msOptions)
|
||||||
|
.where(and(...filters))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new AuthError('Master option not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertParentOption(
|
||||||
|
organizationId: string,
|
||||||
|
parentId?: string | null,
|
||||||
|
currentId?: string
|
||||||
|
): Promise<typeof msOptions.$inferSelect | null> {
|
||||||
|
if (!parentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentId && parentId === currentId) {
|
||||||
|
throw new AuthError('Parent option cannot reference itself', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = await assertOptionBelongsToOrganization(parentId, organizationId);
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertUniqueCode(
|
||||||
|
organizationId: string,
|
||||||
|
category: string,
|
||||||
|
code: string,
|
||||||
|
currentId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
const filters: SQL[] = [
|
||||||
|
eq(msOptions.organizationId, organizationId),
|
||||||
|
eq(msOptions.category, category),
|
||||||
|
eq(msOptions.code, code)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (currentId) {
|
||||||
|
filters.push(ne(msOptions.id, currentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: msOptions.id })
|
||||||
|
.from(msOptions)
|
||||||
|
.where(and(...filters))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new AuthError('Duplicate option code in the same category', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePayload(payload: MasterOptionMutationPayload) {
|
||||||
|
const category = payload.category.trim();
|
||||||
|
const code = normalizeCode(payload.code);
|
||||||
|
const label = payload.label.trim();
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new AuthError('Category is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new AuthError('Code is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
throw new AuthError('Label is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
category,
|
||||||
|
code,
|
||||||
|
label,
|
||||||
|
value: payload.value?.trim() || null,
|
||||||
|
parentId: payload.parentId?.trim() || null,
|
||||||
|
sortOrder: payload.sortOrder ?? 0,
|
||||||
|
isActive: payload.isActive ?? true,
|
||||||
|
metadata: payload.metadata ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function listMasterOptions(
|
export async function listMasterOptions(
|
||||||
filters: MasterOptionListFilters = {}
|
filters: MasterOptionListFilters = {}
|
||||||
): Promise<MasterOptionListResult> {
|
): Promise<MasterOptionListResult> {
|
||||||
@@ -111,7 +247,6 @@ export async function listMasterOptions(
|
|||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const [totalResult] = await db.select({ value: count() }).from(msOptions).where(where);
|
const [totalResult] = await db.select({ value: count() }).from(msOptions).where(where);
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(msOptions)
|
.from(msOptions)
|
||||||
@@ -120,16 +255,8 @@ export async function listMasterOptions(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
const parentIds = [
|
const parentIds = [...new Set(rows.map((row) => row.parentId).filter((value): value is string => Boolean(value)))];
|
||||||
...new Set(rows.map((row) => row.parentId).filter((value): value is string => Boolean(value)))
|
const parentMap = await getParentLabelMap(organizationId, parentIds);
|
||||||
];
|
|
||||||
const parentRows = parentIds.length
|
|
||||||
? await db
|
|
||||||
.select()
|
|
||||||
.from(msOptions)
|
|
||||||
.where(and(eq(msOptions.organizationId, organizationId), inArray(msOptions.id, parentIds)))
|
|
||||||
: [];
|
|
||||||
const parentMap = new Map(parentRows.map((row) => [row.id, row.label]));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: rows.map((row) => mapMasterOption(row, parentMap.get(row.parentId ?? '') ?? null)),
|
items: rows.map((row) => mapMasterOption(row, parentMap.get(row.parentId ?? '') ?? null)),
|
||||||
@@ -137,6 +264,143 @@ export async function listMasterOptions(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMasterOptionById(
|
||||||
|
id: string,
|
||||||
|
organizationId?: string,
|
||||||
|
options?: { includeDeleted?: boolean }
|
||||||
|
): Promise<MasterOptionRecord> {
|
||||||
|
const resolvedOrganizationId = await resolveOrganizationId(organizationId);
|
||||||
|
const row = await assertOptionBelongsToOrganization(id, resolvedOrganizationId, options);
|
||||||
|
const parentLabel =
|
||||||
|
row.parentId && row.parentId !== row.id
|
||||||
|
? (await getParentLabelMap(resolvedOrganizationId, [row.parentId])).get(row.parentId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return mapMasterOption(row, parentLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMasterOption(
|
||||||
|
organizationId: string,
|
||||||
|
payload: MasterOptionMutationPayload
|
||||||
|
): Promise<MasterOptionRecord> {
|
||||||
|
const values = sanitizePayload(payload);
|
||||||
|
const parent = await assertParentOption(organizationId, values.parentId);
|
||||||
|
await assertUniqueCode(organizationId, values.category, values.code);
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(msOptions)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
category: values.category,
|
||||||
|
code: values.code,
|
||||||
|
label: values.label,
|
||||||
|
value: values.value,
|
||||||
|
parentId: parent?.id ?? null,
|
||||||
|
sortOrder: values.sortOrder,
|
||||||
|
isActive: values.isActive,
|
||||||
|
metadata: values.metadata,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return mapMasterOption(created, parent?.label ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMasterOption(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
payload: Partial<MasterOptionMutationPayload>
|
||||||
|
): Promise<MasterOptionRecord> {
|
||||||
|
const current = await assertOptionBelongsToOrganization(id, organizationId);
|
||||||
|
const merged = sanitizePayload({
|
||||||
|
category: payload.category ?? current.category,
|
||||||
|
code: payload.code ?? current.code,
|
||||||
|
label: payload.label ?? current.label,
|
||||||
|
value: payload.value === undefined ? current.value : payload.value,
|
||||||
|
parentId: payload.parentId === undefined ? current.parentId : payload.parentId,
|
||||||
|
sortOrder: payload.sortOrder ?? current.sortOrder,
|
||||||
|
isActive: payload.isActive ?? current.isActive,
|
||||||
|
metadata: payload.metadata === undefined ? ((current.metadata as Record<string, unknown> | null) ?? null) : payload.metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
const parent = await assertParentOption(organizationId, merged.parentId, id);
|
||||||
|
await assertUniqueCode(organizationId, merged.category, merged.code, id);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(msOptions)
|
||||||
|
.set({
|
||||||
|
category: merged.category,
|
||||||
|
code: merged.code,
|
||||||
|
label: merged.label,
|
||||||
|
value: merged.value,
|
||||||
|
parentId: parent?.id ?? null,
|
||||||
|
sortOrder: merged.sortOrder,
|
||||||
|
isActive: merged.isActive,
|
||||||
|
metadata: merged.metadata,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(msOptions.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return mapMasterOption(updated, parent?.label ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function softDeleteMasterOption(
|
||||||
|
id: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<MasterOptionRecord> {
|
||||||
|
const current = await assertOptionBelongsToOrganization(id, organizationId);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(msOptions)
|
||||||
|
.set({
|
||||||
|
isActive: false,
|
||||||
|
deletedAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.where(eq(msOptions.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const parentLabel =
|
||||||
|
current.parentId && current.parentId !== current.id
|
||||||
|
? (await getParentLabelMap(organizationId, [current.parentId])).get(current.parentId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return mapMasterOption(updated, parentLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMasterOptionCategories(
|
||||||
|
organizationId?: string
|
||||||
|
): Promise<MasterOptionCategorySummary[]> {
|
||||||
|
const resolvedOrganizationId = await resolveOrganizationId(organizationId);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(msOptions)
|
||||||
|
.where(and(eq(msOptions.organizationId, resolvedOrganizationId), isNull(msOptions.deletedAt)))
|
||||||
|
.orderBy(asc(msOptions.category), asc(msOptions.sortOrder), asc(msOptions.label));
|
||||||
|
|
||||||
|
const grouped = new Map<string, MasterOptionCategorySummary>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const current = grouped.get(row.category) ?? {
|
||||||
|
category: row.category,
|
||||||
|
totalItems: 0,
|
||||||
|
activeItems: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
current.totalItems += 1;
|
||||||
|
if (row.isActive) {
|
||||||
|
current.activeItems += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped.set(row.category, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.values()];
|
||||||
|
}
|
||||||
|
|
||||||
export async function getOptionsByCategory(
|
export async function getOptionsByCategory(
|
||||||
category: string,
|
category: string,
|
||||||
options?: {
|
options?: {
|
||||||
@@ -149,11 +413,10 @@ export async function getOptionsByCategory(
|
|||||||
const whereFilters = buildFilters(organizationId, {
|
const whereFilters = buildFilters(organizationId, {
|
||||||
category,
|
category,
|
||||||
activeOnly: options?.activeOnly,
|
activeOnly: options?.activeOnly,
|
||||||
includeDeleted: options?.includeDeleted,
|
includeDeleted: options?.includeDeleted
|
||||||
limit: Number.MAX_SAFE_INTEGER,
|
|
||||||
page: 1
|
|
||||||
});
|
});
|
||||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(msOptions)
|
.from(msOptions)
|
||||||
@@ -165,12 +428,10 @@ export async function getOptionsByCategory(
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const parent = row.parentId ? (parentMap.get(row.parentId) ?? null) : null;
|
const parent = row.parentId ? (parentMap.get(row.parentId) ?? null) : null;
|
||||||
itemMap.set(
|
itemMap.set(row.id, {
|
||||||
row.id,
|
...mapMasterOption(row, parent?.label ?? null),
|
||||||
Object.assign(mapMasterOption(row, parent?.label ?? null), {
|
|
||||||
children: []
|
children: []
|
||||||
})
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const roots: MasterOptionNode[] = [];
|
const roots: MasterOptionNode[] = [];
|
||||||
@@ -178,7 +439,6 @@ export async function getOptionsByCategory(
|
|||||||
for (const item of itemMap.values()) {
|
for (const item of itemMap.values()) {
|
||||||
if (item.parentId) {
|
if (item.parentId) {
|
||||||
const parent = itemMap.get(item.parentId);
|
const parent = itemMap.get(item.parentId);
|
||||||
|
|
||||||
if (parent) {
|
if (parent) {
|
||||||
parent.children.push(item);
|
parent.children.push(item);
|
||||||
continue;
|
continue;
|
||||||
@@ -194,7 +454,7 @@ export async function getOptionsByCategory(
|
|||||||
export async function getActiveOptionsByCategory(
|
export async function getActiveOptionsByCategory(
|
||||||
category: string,
|
category: string,
|
||||||
options?: { organizationId?: string }
|
options?: { organizationId?: string }
|
||||||
) {
|
): Promise<MasterOptionNode[]> {
|
||||||
return getOptionsByCategory(category, {
|
return getOptionsByCategory(category, {
|
||||||
organizationId: options?.organizationId,
|
organizationId: options?.organizationId,
|
||||||
activeOnly: true
|
activeOnly: true
|
||||||
|
|||||||
@@ -65,3 +65,20 @@ export interface MasterOptionListResult {
|
|||||||
items: MasterOptionRecord[];
|
items: MasterOptionRecord[];
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MasterOptionMutationPayload {
|
||||||
|
category: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
parentId?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterOptionCategorySummary {
|
||||||
|
category: string;
|
||||||
|
totalItems: number;
|
||||||
|
activeItems: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user