From 071293b60fb3766a6979cfe0f321a8103ed32ffc Mon Sep 17 00:00:00 2001 From: phaichayon Date: Sat, 27 Jun 2026 22:14:16 +0700 Subject: [PATCH] task-fic-display --- docs/operations/system-bootstrap.md | 47 ++ package.json | 20 +- plans/task-fix-display.md | 772 ++++++++++++++++++ scripts/db/reset-database.ts | 97 +++ .../foundation/master-options/[id]/route.ts | 156 ++++ .../master-options/categories/route.ts | 34 + .../api/foundation/master-options/route.ts | 104 ++- .../api/foundation/ms-options/[id]/route.ts | 1 + .../foundation/ms-options/categories/route.ts | 1 + src/app/api/foundation/ms-options/route.ts | 1 + src/db/seeds/system.seed.ts | 19 + src/db/seeds/uat.seed.ts | 18 + src/features/crm/customers/api/types.ts | 5 +- .../customers/components/customer-detail.tsx | 4 +- src/features/crm/customers/server/service.ts | 56 +- src/features/crm/quotations/api/types.ts | 5 + .../components/quotation-detail.tsx | 4 +- src/features/crm/quotations/server/service.ts | 59 +- .../display/server/branch-display-resolver.ts | 31 + .../display/server/display-resolver.ts | 3 + .../display/server/option-display-resolver.ts | 43 + .../display/server/user-display-resolver.ts | 52 ++ .../master-options/api/mutations.ts | 60 +- .../foundation/master-options/api/queries.ts | 26 +- .../foundation/master-options/api/service.ts | 51 +- .../foundation/master-options/api/types.ts | 36 + .../components/master-options-form-dialog.tsx | 241 ++++++ .../components/master-options-table.tsx | 135 ++- .../foundation/master-options/service.ts | 338 +++++++- .../foundation/master-options/types.ts | 17 + 30 files changed, 2329 insertions(+), 107 deletions(-) create mode 100644 docs/operations/system-bootstrap.md create mode 100644 plans/task-fix-display.md create mode 100644 scripts/db/reset-database.ts create mode 100644 src/app/api/foundation/master-options/[id]/route.ts create mode 100644 src/app/api/foundation/master-options/categories/route.ts create mode 100644 src/app/api/foundation/ms-options/[id]/route.ts create mode 100644 src/app/api/foundation/ms-options/categories/route.ts create mode 100644 src/app/api/foundation/ms-options/route.ts create mode 100644 src/db/seeds/system.seed.ts create mode 100644 src/db/seeds/uat.seed.ts create mode 100644 src/features/foundation/display/server/branch-display-resolver.ts create mode 100644 src/features/foundation/display/server/display-resolver.ts create mode 100644 src/features/foundation/display/server/option-display-resolver.ts create mode 100644 src/features/foundation/display/server/user-display-resolver.ts create mode 100644 src/features/foundation/master-options/components/master-options-form-dialog.tsx diff --git a/docs/operations/system-bootstrap.md b/docs/operations/system-bootstrap.md new file mode 100644 index 0000000..4c2e1b9 --- /dev/null +++ b/docs/operations/system-bootstrap.md @@ -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 diff --git a/package.json b/package.json index 65897c0..d8e2825 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,30 @@ "format:check": "oxfmt --check .", "prepare": "husky", "gen": "npm run verify:encoding && npx drizzle-kit generate", - "migrate": "npx drizzle-kit migrate", - "studio": "npx drizzle-kit studio", + "db:generate": "npx drizzle-kit generate", + "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: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", + "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", "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-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", "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", @@ -35,7 +49,7 @@ "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" + "setup:db": "npm run db:setup" }, "dependencies": { "@aws-sdk/client-s3": "^3.1069.0", diff --git a/plans/task-fix-display.md b/plans/task-fix-display.md new file mode 100644 index 0000000..777b32e --- /dev/null +++ b/plans/task-fix-display.md @@ -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 +``` diff --git a/scripts/db/reset-database.ts b/scripts/db/reset-database.ts new file mode 100644 index 0000000..8f996de --- /dev/null +++ b/scripts/db/reset-database.ts @@ -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(); diff --git a/src/app/api/foundation/master-options/[id]/route.ts b/src/app/api/foundation/master-options/[id]/route.ts new file mode 100644 index 0000000..cc68b38 --- /dev/null +++ b/src/app/api/foundation/master-options/[id]/route.ts @@ -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>) { + 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 }); + } +} diff --git a/src/app/api/foundation/master-options/categories/route.ts b/src/app/api/foundation/master-options/categories/route.ts new file mode 100644 index 0000000..f1e9acf --- /dev/null +++ b/src/app/api/foundation/master-options/categories/route.ts @@ -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 }); + } +} diff --git a/src/app/api/foundation/master-options/route.ts b/src/app/api/foundation/master-options/route.ts index 233bcd1..984dcda 100644 --- a/src/app/api/foundation/master-options/route.ts +++ b/src/app/api/foundation/master-options/route.ts @@ -1,12 +1,47 @@ 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 { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; +const masterOptionSchema = z.object({ + category: z.string().min(1), + code: z.string().min(1), + label: z.string().min(1), + 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>) { + 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 + }; +} + export async function GET(request: NextRequest) { try { const { organization } = await requireOrganizationAccess({ - permission: PERMISSIONS.organizationManage + permission: PERMISSIONS.crmMasterOptionRead }); const { searchParams } = request.nextUrl; const page = Number(searchParams.get('page') ?? 1); @@ -14,6 +49,7 @@ export async function GET(request: NextRequest) { 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, @@ -22,29 +58,15 @@ export async function GET(request: NextRequest) { category, sort }); - const offset = (page - 1) * limit; return NextResponse.json({ success: true, time: new Date().toISOString(), message: 'Master options loaded successfully', total_items: result.totalItems, - offset, + offset: (page - 1) * limit, limit, - items: result.items.map((item) => ({ - 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, - deleted_at: item.deletedAt, - updated_at: item.updatedAt - })) + items: result.items.map(mapMasterOptionResponse) }); } catch (error) { 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 }); } } + +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 }); + } +} diff --git a/src/app/api/foundation/ms-options/[id]/route.ts b/src/app/api/foundation/ms-options/[id]/route.ts new file mode 100644 index 0000000..944febc --- /dev/null +++ b/src/app/api/foundation/ms-options/[id]/route.ts @@ -0,0 +1 @@ +export { DELETE, GET, PATCH } from '@/app/api/foundation/master-options/[id]/route'; diff --git a/src/app/api/foundation/ms-options/categories/route.ts b/src/app/api/foundation/ms-options/categories/route.ts new file mode 100644 index 0000000..d6fdfb9 --- /dev/null +++ b/src/app/api/foundation/ms-options/categories/route.ts @@ -0,0 +1 @@ +export { GET } from '@/app/api/foundation/master-options/categories/route'; diff --git a/src/app/api/foundation/ms-options/route.ts b/src/app/api/foundation/ms-options/route.ts new file mode 100644 index 0000000..0180d5a --- /dev/null +++ b/src/app/api/foundation/ms-options/route.ts @@ -0,0 +1 @@ +export { GET, POST } from '@/app/api/foundation/master-options/route'; diff --git a/src/db/seeds/system.seed.ts b/src/db/seeds/system.seed.ts new file mode 100644 index 0000000..f5ddde0 --- /dev/null +++ b/src/db/seeds/system.seed.ts @@ -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(); diff --git a/src/db/seeds/uat.seed.ts b/src/db/seeds/uat.seed.ts new file mode 100644 index 0000000..1fae074 --- /dev/null +++ b/src/db/seeds/uat.seed.ts @@ -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(); diff --git a/src/features/crm/customers/api/types.ts b/src/features/crm/customers/api/types.ts index 34c8b5e..dbda96b 100644 --- a/src/features/crm/customers/api/types.ts +++ b/src/features/crm/customers/api/types.ts @@ -17,6 +17,8 @@ export interface CustomerRecord { id: string; organizationId: string; branchId: string | null; + branchName: string | null; + branchCode: string | null; code: string; name: string; abbr: string | null; @@ -47,7 +49,9 @@ export interface CustomerRecord { updatedAt: string; deletedAt: string | null; createdBy: string; + createdByName: string | null; updatedBy: string; + updatedByName: string | null; } export interface CustomerListItem extends CustomerRecord { @@ -230,4 +234,3 @@ export interface MutationSuccessResponse { success: boolean; message: string; } - diff --git a/src/features/crm/customers/components/customer-detail.tsx b/src/features/crm/customers/components/customer-detail.tsx index 62fbccd..5172910 100644 --- a/src/features/crm/customers/components/customer-detail.tsx +++ b/src/features/crm/customers/components/customer-detail.tsx @@ -301,8 +301,8 @@ export function CustomerDetail({ ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้ - - + + diff --git a/src/features/crm/customers/server/service.ts b/src/features/crm/customers/server/service.ts index d73fc47..85098c2 100644 --- a/src/features/crm/customers/server/service.ts +++ b/src/features/crm/customers/server/service.ts @@ -34,6 +34,10 @@ import { getUserBranches, } from "@/features/foundation/branch-scope/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 { canAccessContact, @@ -64,6 +68,13 @@ type CustomerRecordSource = Omit< ownerAssignedByName?: string | null; }; +type CustomerDisplayOptions = { + branchName?: string | null; + branchCode?: string | null; + createdByName?: string | null; + updatedByName?: string | null; +}; + let customerSubGroupColumnAvailable: boolean | undefined; export type CustomerAccessContext = CrmSecurityContext; @@ -154,11 +165,16 @@ function toOptionalIsoDateTime(value: Date | string | null | undefined) { return value ? toIsoDateTime(value) : null; } -function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord { +function mapCustomerRecord( + row: CustomerRecordSource, + options?: CustomerDisplayOptions, +): CustomerRecord { return { id: row.id, organizationId: row.organizationId, branchId: row.branchId, + branchName: options?.branchName ?? null, + branchCode: options?.branchCode ?? null, code: row.code, name: row.name, abbr: row.abbr, @@ -189,7 +205,9 @@ function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord { updatedAt: toIsoDateTime(row.updatedAt), deletedAt: toOptionalIsoDateTime(row.deletedAt), createdBy: row.createdBy, + createdByName: options?.createdByName ?? null, updatedBy: row.updatedBy, + updatedByName: options?.updatedByName ?? null, }; } @@ -956,26 +974,35 @@ export async function getCustomerDetail( organizationId, accessContext, ); - const relatedUserIds = [ - customer.ownerUserId, - customer.ownerAssignedBy, - ].filter(Boolean) as string[]; - const userRows = relatedUserIds.length - ? await db - .select({ id: users.id, name: users.name }) - .from(users) - .where(inArray(users.id, relatedUserIds)) - : []; - const userMap = new Map(userRows.map((row) => [row.id, row.name])); + const [userDisplayMap, branchDisplayMap] = await Promise.all([ + resolveUserDisplays([ + customer.ownerUserId, + customer.ownerAssignedBy, + customer.createdBy, + customer.updatedBy, + ]), + resolveBranchDisplays({ + organizationId, + branchIds: [customer.branchId], + }), + ]); + const branchDisplay = customer.branchId + ? (branchDisplayMap.get(customer.branchId) ?? null) + : null; return mapCustomerRecord({ ...customer, ownerName: customer.ownerUserId - ? (userMap.get(customer.ownerUserId) ?? null) + ? (userDisplayMap.get(customer.ownerUserId)?.displayName ?? null) : null, ownerAssignedByName: customer.ownerAssignedBy - ? (userMap.get(customer.ownerAssignedBy) ?? null) + ? (userDisplayMap.get(customer.ownerAssignedBy)?.displayName ?? 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; } - diff --git a/src/features/crm/quotations/api/types.ts b/src/features/crm/quotations/api/types.ts index a690ead..9a64b93 100644 --- a/src/features/crm/quotations/api/types.ts +++ b/src/features/crm/quotations/api/types.ts @@ -63,6 +63,8 @@ export interface QuotationRecord { id: string; organizationId: string; branchId: string | null; + branchName: string | null; + branchCode: string | null; code: string; opportunityId: string | null; customerId: string; @@ -91,6 +93,7 @@ export interface QuotationRecord { isHotProject: boolean; competitor: string | null; salesmanId: string | null; + salesmanName: string | null; isSent: boolean; sentAt: string | null; sentVia: string | null; @@ -109,7 +112,9 @@ export interface QuotationRecord { updatedAt: string; deletedAt: string | null; createdBy: string; + createdByName: string | null; updatedBy: string; + updatedByName: string | null; } export interface QuotationListItem extends QuotationRecord { diff --git a/src/features/crm/quotations/components/quotation-detail.tsx b/src/features/crm/quotations/components/quotation-detail.tsx index 2b17e24..5ab56af 100644 --- a/src/features/crm/quotations/components/quotation-detail.tsx +++ b/src/features/crm/quotations/components/quotation-detail.tsx @@ -1716,8 +1716,8 @@ export function QuotationDetail({ Operational metadata for this quotation row. - - + + { 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 = quotation.approvedArtifactId ?? (quotation.approvedPdfUrl?.startsWith('artifact:') @@ -1218,7 +1255,7 @@ export async function getQuotationDetail( : null); if (!approvedArtifactId) { - return mapQuotationRecord(quotation); + return mapQuotationRecord(quotation, baseDisplayOptions); } const [artifact] = await db @@ -1237,19 +1274,15 @@ export async function getQuotationDetail( return mapQuotationRecord(quotation); } - const relatedUserIds = [artifact.generatedBy, artifact.lockedBy].filter(Boolean) as string[]; - 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])); + const artifactActorMap = await resolveUserDisplays([artifact.generatedBy, artifact.lockedBy]); return mapQuotationRecord(quotation, { + ...baseDisplayOptions, approvedArtifact: mapApprovedArtifactSummary(artifact, { - generatedByName: actorMap.get(artifact.generatedBy) ?? null, - lockedByName: artifact.lockedBy ? (actorMap.get(artifact.lockedBy) ?? null) : null + generatedByName: artifactActorMap.get(artifact.generatedBy)?.displayName ?? null, + lockedByName: artifact.lockedBy + ? (artifactActorMap.get(artifact.lockedBy)?.displayName ?? null) + : null }) }); } diff --git a/src/features/foundation/display/server/branch-display-resolver.ts b/src/features/foundation/display/server/branch-display-resolver.ts new file mode 100644 index 0000000..9bcc146 --- /dev/null +++ b/src/features/foundation/display/server/branch-display-resolver.ts @@ -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; +}) { + 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 + } + ]) + ); +} diff --git a/src/features/foundation/display/server/display-resolver.ts b/src/features/foundation/display/server/display-resolver.ts new file mode 100644 index 0000000..f9dc276 --- /dev/null +++ b/src/features/foundation/display/server/display-resolver.ts @@ -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'; diff --git a/src/features/foundation/display/server/option-display-resolver.ts b/src/features/foundation/display/server/option-display-resolver.ts new file mode 100644 index 0000000..a070b5c --- /dev/null +++ b/src/features/foundation/display/server/option-display-resolver.ts @@ -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; +}) { + const uniqueIds = [...new Set(input.optionIds.filter((value): value is string => Boolean(value)))]; + + if (!uniqueIds.length) { + return new Map(); + } + + 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])); +} diff --git a/src/features/foundation/display/server/user-display-resolver.ts b/src/features/foundation/display/server/user-display-resolver.ts new file mode 100644 index 0000000..58c133c --- /dev/null +++ b/src/features/foundation/display/server/user-display-resolver.ts @@ -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) { + const uniqueIds = [...new Set(userIds.filter((value): value is string => Boolean(value)))]; + + if (!uniqueIds.length) { + return new Map(); + } + + 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) + } + ]) + ); +} diff --git a/src/features/foundation/master-options/api/mutations.ts b/src/features/foundation/master-options/api/mutations.ts index cb0ff5c..162f471 100644 --- a/src/features/foundation/master-options/api/mutations.ts +++ b/src/features/foundation/master-options/api/mutations.ts @@ -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; + }) => 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) }); + } + } +}); diff --git a/src/features/foundation/master-options/api/queries.ts b/src/features/foundation/master-options/api/queries.ts index 6c20bd2..498e962 100644 --- a/src/features/foundation/master-options/api/queries.ts +++ b/src/features/foundation/master-options/api/queries.ts @@ -1,10 +1,18 @@ import { queryOptions } from '@tanstack/react-query'; -import { getMasterOptions } from './service'; +import { + getMasterOptionById, + getMasterOptionCategories, + getMasterOptions +} from './service'; import type { MasterOptionsFilters } from './types'; export const masterOptionKeys = { - all: ['foundation', 'master-options'] as const, - list: (filters: MasterOptionsFilters) => [...masterOptionKeys.all, 'list', filters] as const + all: ['foundation', 'ms-options'] 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) => @@ -12,3 +20,15 @@ export const masterOptionsQueryOptions = (filters: MasterOptionsFilters) => queryKey: masterOptionKeys.list(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() + }); diff --git a/src/features/foundation/master-options/api/service.ts b/src/features/foundation/master-options/api/service.ts index 0f83632..ad0a544 100644 --- a/src/features/foundation/master-options/api/service.ts +++ b/src/features/foundation/master-options/api/service.ts @@ -1,9 +1,16 @@ 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( - filters: MasterOptionsFilters -): Promise { +const BASE_PATH = '/foundation/ms-options'; + +export async function getMasterOptions(filters: MasterOptionsFilters): Promise { const searchParams = new URLSearchParams(); 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); const query = searchParams.toString(); - - return apiClient(`/foundation/master-options${query ? `?${query}` : ''}`); + return apiClient(`${BASE_PATH}${query ? `?${query}` : ''}`); +} + +export async function getMasterOptionById(id: string): Promise { + return apiClient(`${BASE_PATH}/${id}`); +} + +export async function createMasterOption( + data: MasterOptionMutationPayload +): Promise { + return apiClient(BASE_PATH, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateMasterOption( + id: string, + data: Partial +): Promise { + return apiClient(`${BASE_PATH}/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteMasterOption(id: string): Promise { + return apiClient(`${BASE_PATH}/${id}`, { + method: 'DELETE' + }); +} + +export async function getMasterOptionCategories(): Promise { + return apiClient(`${BASE_PATH}/categories`); } diff --git a/src/features/foundation/master-options/api/types.ts b/src/features/foundation/master-options/api/types.ts index f972bfc..f9b875f 100644 --- a/src/features/foundation/master-options/api/types.ts +++ b/src/features/foundation/master-options/api/types.ts @@ -9,7 +9,9 @@ export interface MasterOptionItem { parent_label: string | null; sort_order: number; is_active: boolean; + metadata: Record | null; deleted_at: string | null; + created_at: string; updated_at: string; } @@ -21,6 +23,17 @@ export interface MasterOptionsFilters { 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 | null; +} + export interface MasterOptionsResponse { success: boolean; time: string; @@ -30,3 +43,26 @@ export interface MasterOptionsResponse { limit: number; 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; +} diff --git a/src/features/foundation/master-options/components/master-options-form-dialog.tsx b/src/features/foundation/master-options/components/master-options-form-dialog.tsx new file mode 100644 index 0000000..267fa03 --- /dev/null +++ b/src/features/foundation/master-options/components/master-options-form-dialog.tsx @@ -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; + pending: boolean; + option?: MasterOptionItem; + availableParents: MasterOptionItem[]; +}) { + const [state, setState] = useState(buildInitialState(option)); + const [jsonError, setJsonError] = useState(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) : 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 ( + + + + {option ? 'Edit master option' : 'Create master option'} + + Manage organization-scoped lookup values without hardcoding UI labels. + + +
+
+
+ + +
+
+ + + setState((current) => ({ ...current, code: event.target.value })) + } + placeholder='snake_case_code' + /> +
+
+
+
+ + + setState((current) => ({ ...current, label: event.target.value })) + } + placeholder='Display label' + /> +
+
+ + + setState((current) => ({ ...current, value: event.target.value })) + } + placeholder='Optional value' + /> +
+
+
+
+ + +
+
+ + + setState((current) => ({ ...current, sort_order: event.target.value })) + } + /> +
+
+
+
+
Active option
+
+ Inactive options stay available for admin review but should disappear from normal + dropdowns. +
+
+ + setState((current) => ({ ...current, is_active: checked })) + } + /> +
+
+ +