task-fic-display

This commit is contained in:
phaichayon
2026-06-27 22:14:16 +07:00
parent 1c019d12f3
commit 071293b60f
30 changed files with 2329 additions and 107 deletions

View File

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

View File

@@ -301,8 +301,8 @@ export function CustomerDetail({
<CardDescription></CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<FieldItem label='Created By' value={customer.createdBy} />
<FieldItem label='Updated By' value={customer.updatedBy} />
<FieldItem label='Created By' value={customer.createdByName ?? customer.createdBy} />
<FieldItem label='Updated By' value={customer.updatedByName ?? customer.updatedBy} />
<FieldItem label='Created At' value={formatDateTime(customer.createdAt)} />
<FieldItem label='Updated At' value={formatDateTime(customer.updatedAt)} />
</CardContent>

View File

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

View File

@@ -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 {

View File

@@ -1716,8 +1716,8 @@ export function QuotationDetail({
<CardDescription>Operational metadata for this quotation row.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<FieldItem label='Created By' value={quotation.createdBy} />
<FieldItem label='Updated By' value={quotation.updatedBy} />
<FieldItem label='Created By' value={quotation.createdByName ?? quotation.createdBy} />
<FieldItem label='Updated By' value={quotation.updatedByName ?? quotation.updatedBy} />
<FieldItem
label='Created At'
value={formatDateTime(quotation.createdAt)}

View File

@@ -24,6 +24,10 @@ import {
canAccessScopedCrmRecord
} from '@/features/crm/security/server/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 { syncOpportunityPipelineStageFromQuotationStatus } from '@/features/crm/opportunities/server/service';
import {
@@ -130,12 +134,21 @@ function mapApprovedArtifactSummary(
function mapQuotationRecord(
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 {
return {
id: row.id,
organizationId: row.organizationId,
branchId: row.branchId,
branchName: options?.branchName ?? null,
branchCode: options?.branchCode ?? null,
code: row.code,
opportunityId: row.opportunityId,
customerId: row.customerId,
@@ -164,6 +177,7 @@ function mapQuotationRecord(
isHotProject: row.isHotProject,
competitor: row.competitor,
salesmanId: row.salesmanId,
salesmanName: options?.salesmanName ?? null,
isSent: row.isSent,
sentAt: row.sentAt?.toISOString() ?? null,
sentVia: row.sentVia,
@@ -185,7 +199,9 @@ function mapQuotationRecord(
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null,
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
): Promise<QuotationRecord> {
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
})
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<MasterOptionsResponse> {
const BASE_PATH = '/foundation/ms-options';
export async function getMasterOptions(filters: MasterOptionsFilters): Promise<MasterOptionsResponse> {
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<MasterOptionsResponse>(`/foundation/master-options${query ? `?${query}` : ''}`);
return apiClient<MasterOptionsResponse>(`${BASE_PATH}${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`);
}

View File

@@ -9,7 +9,9 @@ export interface MasterOptionItem {
parent_label: string | null;
sort_order: number;
is_active: boolean;
metadata: Record<string, unknown> | 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<string, unknown> | 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;
}

View File

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

View File

@@ -1,13 +1,32 @@
'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 { 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 { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { useDataTable } from '@/hooks/use-data-table';
import { getSortingStateParser } from '@/lib/parsers';
import {
createMasterOptionMutation,
deleteMasterOptionMutation,
updateMasterOptionMutation
} from '../api/mutations';
import { masterOptionsQueryOptions } from '../api/queries';
import type { MasterOptionItem, MasterOptionMutationPayload } from '../api/types';
import { columns } from './columns';
import { MasterOptionsFormDialog } from './master-options-form-dialog';
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
@@ -19,6 +38,9 @@ export function MasterOptionsTable() {
category: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const [editingOption, setEditingOption] = useState<MasterOptionItem | undefined>();
const [isFormOpen, setIsFormOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<MasterOptionItem | null>(null);
const filters = {
page: params.page,
@@ -27,20 +49,125 @@ export function MasterOptionsTable() {
...(params.category && { category: params.category }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
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 { table } = useDataTable({
data: data.items,
columns,
columns: tableColumns,
pageCount,
shallow: true,
debounceMs: 500
});
async function handleSubmit(values: MasterOptionMutationPayload) {
if (editingOption) {
await updateMutation.mutateAsync({
id: editingOption.id,
values
});
return;
}
await createMutation.mutateAsync(values);
}
return (
<DataTable table={table}>
<DataTableToolbar table={table} />
<div className='flex flex-wrap items-center justify-between gap-3'>
<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>
);
}

View File

@@ -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 { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import type {
MasterOptionCategorySummary,
MasterOptionListFilters,
MasterOptionListResult,
MasterOptionMutationPayload,
MasterOptionNode,
MasterOptionRecord
} 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) {
return organizationId;
}
const organization = await getCurrentOrganization();
if (!organization) {
throw new Error('Active organization is required');
throw new Error('Active organization required');
}
return organization.id;
@@ -31,7 +55,6 @@ function parseSort(sort?: string) {
try {
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
const [rule] = parsed;
if (!rule) {
return desc(msOptions.updatedAt);
}
@@ -46,7 +69,6 @@ function parseSort(sort?: string) {
} as const;
const column = sortMap[rule.id as keyof typeof sortMap];
if (!column) {
return desc(msOptions.updatedAt);
}
@@ -58,17 +80,19 @@ function parseSort(sort?: string) {
}
function buildFilters(organizationId: string, filters: MasterOptionListFilters): SQL[] {
const search = filters.search?.trim();
return [
eq(msOptions.organizationId, organizationId),
...(filters.category ? [eq(msOptions.category, filters.category)] : []),
...(filters.activeOnly ? [eq(msOptions.isActive, true)] : []),
...(!filters.includeDeleted ? [isNull(msOptions.deletedAt)] : []),
...(filters.search
...(search
? [
or(
ilike(msOptions.label, `%${filters.search}%`),
ilike(msOptions.code, `%${filters.search}%`),
ilike(msOptions.category, `%${filters.search}%`)
ilike(msOptions.label, `%${search}%`),
ilike(msOptions.code, `%${search}%`),
ilike(msOptions.category, `%${search}%`)
)!
]
: [])
@@ -85,21 +109,133 @@ function mapMasterOption(
category: row.category,
code: row.code,
label: row.label,
value: row.value,
parentId: row.parentId,
value: row.value ?? null,
parentId: row.parentId ?? null,
parentLabel,
sortOrder: row.sortOrder,
isActive: row.isActive,
metadata:
row.metadata && typeof row.metadata === 'object'
? (row.metadata as Record<string, unknown>)
: null,
deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
deletedAt: row.deletedAt?.toISOString() ?? null,
createdAt: row.createdAt.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(
filters: MasterOptionListFilters = {}
): Promise<MasterOptionListResult> {
@@ -111,7 +247,6 @@ export async function listMasterOptions(
const offset = (page - 1) * limit;
const [totalResult] = await db.select({ value: count() }).from(msOptions).where(where);
const rows = await db
.select()
.from(msOptions)
@@ -120,16 +255,8 @@ export async function listMasterOptions(
.limit(limit)
.offset(offset);
const parentIds = [
...new Set(rows.map((row) => row.parentId).filter((value): value is string => Boolean(value)))
];
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]));
const parentIds = [...new Set(rows.map((row) => row.parentId).filter((value): value is string => Boolean(value)))];
const parentMap = await getParentLabelMap(organizationId, parentIds);
return {
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(
category: string,
options?: {
@@ -149,11 +413,10 @@ export async function getOptionsByCategory(
const whereFilters = buildFilters(organizationId, {
category,
activeOnly: options?.activeOnly,
includeDeleted: options?.includeDeleted,
limit: Number.MAX_SAFE_INTEGER,
page: 1
includeDeleted: options?.includeDeleted
});
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const rows = await db
.select()
.from(msOptions)
@@ -165,12 +428,10 @@ export async function getOptionsByCategory(
for (const row of rows) {
const parent = row.parentId ? (parentMap.get(row.parentId) ?? null) : null;
itemMap.set(
row.id,
Object.assign(mapMasterOption(row, parent?.label ?? null), {
children: []
})
);
itemMap.set(row.id, {
...mapMasterOption(row, parent?.label ?? null),
children: []
});
}
const roots: MasterOptionNode[] = [];
@@ -178,7 +439,6 @@ export async function getOptionsByCategory(
for (const item of itemMap.values()) {
if (item.parentId) {
const parent = itemMap.get(item.parentId);
if (parent) {
parent.children.push(item);
continue;
@@ -194,7 +454,7 @@ export async function getOptionsByCategory(
export async function getActiveOptionsByCategory(
category: string,
options?: { organizationId?: string }
) {
): Promise<MasterOptionNode[]> {
return getOptionsByCategory(category, {
organizationId: options?.organizationId,
activeOnly: true

View File

@@ -65,3 +65,20 @@ export interface MasterOptionListResult {
items: MasterOptionRecord[];
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;
}