task-fic-display
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { resolveOptionLabels } from './option-display-resolver';
|
||||
|
||||
export type BranchDisplayRecord = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
export async function resolveBranchDisplays(input: {
|
||||
organizationId: string;
|
||||
branchIds: Array<string | null | undefined>;
|
||||
}) {
|
||||
const optionMap = await resolveOptionLabels({
|
||||
organizationId: input.organizationId,
|
||||
category: 'crm_branch',
|
||||
optionIds: input.branchIds
|
||||
});
|
||||
|
||||
return new Map(
|
||||
[...optionMap.entries()].map(([id, row]) => [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
code: row.code,
|
||||
name: row.label,
|
||||
value: row.value
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { resolveBranchDisplays, type BranchDisplayRecord } from './branch-display-resolver';
|
||||
export { resolveOptionLabels, type OptionDisplayRecord } from './option-display-resolver';
|
||||
export { resolveUserDisplays, type UserDisplayRecord } from './user-display-resolver';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { msOptions } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export type OptionDisplayRecord = {
|
||||
id: string;
|
||||
category: string;
|
||||
code: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
export async function resolveOptionLabels(input: {
|
||||
organizationId: string;
|
||||
category: string;
|
||||
optionIds: Array<string | null | undefined>;
|
||||
}) {
|
||||
const uniqueIds = [...new Set(input.optionIds.filter((value): value is string => Boolean(value)))];
|
||||
|
||||
if (!uniqueIds.length) {
|
||||
return new Map<string, OptionDisplayRecord>();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: msOptions.id,
|
||||
category: msOptions.category,
|
||||
code: msOptions.code,
|
||||
label: msOptions.label,
|
||||
value: msOptions.value
|
||||
})
|
||||
.from(msOptions)
|
||||
.where(
|
||||
and(
|
||||
eq(msOptions.organizationId, input.organizationId),
|
||||
eq(msOptions.category, input.category),
|
||||
inArray(msOptions.id, uniqueIds),
|
||||
isNull(msOptions.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { users } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export type UserDisplayRecord = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
function resolveDisplayName(row: { id: string; name: string | null; email: string }) {
|
||||
const name = row.name?.trim();
|
||||
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const email = row.email.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
|
||||
return row.id;
|
||||
}
|
||||
|
||||
export async function resolveUserDisplays(userIds: Array<string | null | undefined>) {
|
||||
const uniqueIds = [...new Set(userIds.filter((value): value is string => Boolean(value)))];
|
||||
|
||||
if (!uniqueIds.length) {
|
||||
return new Map<string, UserDisplayRecord>();
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email
|
||||
})
|
||||
.from(users)
|
||||
.where(inArray(users.id, uniqueIds));
|
||||
|
||||
return new Map(
|
||||
rows.map((row) => [
|
||||
row.id,
|
||||
{
|
||||
...row,
|
||||
displayName: resolveDisplayName(row)
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -1 +1,59 @@
|
||||
export {};
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
createMasterOption,
|
||||
deleteMasterOption,
|
||||
updateMasterOption
|
||||
} from './service';
|
||||
import { masterOptionKeys } from './queries';
|
||||
import type { MasterOptionMutationPayload } from './types';
|
||||
|
||||
async function invalidateMasterOptionLists() {
|
||||
const queryClient = getQueryClient();
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: masterOptionKeys.lists() }),
|
||||
queryClient.invalidateQueries({ queryKey: masterOptionKeys.categories() })
|
||||
]);
|
||||
}
|
||||
|
||||
async function invalidateMasterOptionDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: masterOptionKeys.detail(id) });
|
||||
}
|
||||
|
||||
export const createMasterOptionMutation = mutationOptions({
|
||||
mutationFn: (data: MasterOptionMutationPayload) => createMasterOption(data),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateMasterOptionLists();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const updateMasterOptionMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
id,
|
||||
values
|
||||
}: {
|
||||
id: string;
|
||||
values: Partial<MasterOptionMutationPayload>;
|
||||
}) => updateMasterOption(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateMasterOptionLists(),
|
||||
invalidateMasterOptionDetail(variables.id)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteMasterOptionMutation = mutationOptions({
|
||||
mutationFn: (id: string) => deleteMasterOption(id),
|
||||
onSettled: async (_data, error, id) => {
|
||||
if (!error) {
|
||||
const queryClient = getQueryClient();
|
||||
await invalidateMasterOptionLists();
|
||||
queryClient.removeQueries({ queryKey: masterOptionKeys.detail(id) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { 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()
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CRM_MASTER_OPTION_CATEGORIES } from '@/features/foundation/master-options/types';
|
||||
import type { MasterOptionItem, MasterOptionMutationPayload } from '../api/types';
|
||||
|
||||
const EMPTY_METADATA = '{}';
|
||||
|
||||
type FormState = {
|
||||
category: string;
|
||||
code: string;
|
||||
label: string;
|
||||
value: string;
|
||||
parent_id: string;
|
||||
sort_order: string;
|
||||
is_active: boolean;
|
||||
metadata: string;
|
||||
};
|
||||
|
||||
function buildInitialState(option?: MasterOptionItem): FormState {
|
||||
return {
|
||||
category: option?.category ?? CRM_MASTER_OPTION_CATEGORIES[0] ?? '',
|
||||
code: option?.code ?? '',
|
||||
label: option?.label ?? '',
|
||||
value: option?.value ?? '',
|
||||
parent_id: option?.parent_id ?? '__none__',
|
||||
sort_order: String(option?.sort_order ?? 0),
|
||||
is_active: option?.is_active ?? true,
|
||||
metadata: option?.metadata ? JSON.stringify(option.metadata, null, 2) : EMPTY_METADATA
|
||||
};
|
||||
}
|
||||
|
||||
export function MasterOptionsFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
pending,
|
||||
option,
|
||||
availableParents
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: MasterOptionMutationPayload) => Promise<void>;
|
||||
pending: boolean;
|
||||
option?: MasterOptionItem;
|
||||
availableParents: MasterOptionItem[];
|
||||
}) {
|
||||
const [state, setState] = useState<FormState>(buildInitialState(option));
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setState(buildInitialState(option));
|
||||
setJsonError(null);
|
||||
}
|
||||
}, [open, option]);
|
||||
|
||||
const parentOptions = useMemo(
|
||||
() =>
|
||||
availableParents.filter(
|
||||
(item) => item.category === state.category && item.id !== option?.id
|
||||
),
|
||||
[availableParents, option?.id, state.category]
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
const metadataValue = state.metadata.trim();
|
||||
const metadata =
|
||||
metadataValue.length > 0 ? (JSON.parse(metadataValue) as Record<string, unknown>) : null;
|
||||
|
||||
setJsonError(null);
|
||||
|
||||
await onSubmit({
|
||||
category: state.category,
|
||||
code: state.code,
|
||||
label: state.label,
|
||||
value: state.value || null,
|
||||
parent_id: state.parent_id === '__none__' ? null : state.parent_id,
|
||||
sort_order: Number(state.sort_order || '0'),
|
||||
is_active: state.is_active,
|
||||
metadata
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
setJsonError('Metadata must be valid JSON.');
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{option ? 'Edit master option' : 'Create master option'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage organization-scoped lookup values without hardcoding UI labels.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='grid gap-4 py-2'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='master-option-category'>Category</Label>
|
||||
<Select
|
||||
value={state.category}
|
||||
onValueChange={(value) => setState((current) => ({ ...current, category: value }))}
|
||||
>
|
||||
<SelectTrigger id='master-option-category'>
|
||||
<SelectValue placeholder='Select category' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CRM_MASTER_OPTION_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='master-option-code'>Code</Label>
|
||||
<Input
|
||||
id='master-option-code'
|
||||
value={state.code}
|
||||
onChange={(event) =>
|
||||
setState((current) => ({ ...current, code: event.target.value }))
|
||||
}
|
||||
placeholder='snake_case_code'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='master-option-label'>Label</Label>
|
||||
<Input
|
||||
id='master-option-label'
|
||||
value={state.label}
|
||||
onChange={(event) =>
|
||||
setState((current) => ({ ...current, label: event.target.value }))
|
||||
}
|
||||
placeholder='Display label'
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='master-option-value'>Value</Label>
|
||||
<Input
|
||||
id='master-option-value'
|
||||
value={state.value}
|
||||
onChange={(event) =>
|
||||
setState((current) => ({ ...current, value: event.target.value }))
|
||||
}
|
||||
placeholder='Optional value'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='master-option-parent'>Parent</Label>
|
||||
<Select
|
||||
value={state.parent_id}
|
||||
onValueChange={(value) =>
|
||||
setState((current) => ({ ...current, parent_id: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id='master-option-parent'>
|
||||
<SelectValue placeholder='No parent' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>No parent</SelectItem>
|
||||
{parentOptions.map((parent) => (
|
||||
<SelectItem key={parent.id} value={parent.id}>
|
||||
{parent.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='master-option-sort-order'>Sort order</Label>
|
||||
<Input
|
||||
id='master-option-sort-order'
|
||||
type='number'
|
||||
value={state.sort_order}
|
||||
onChange={(event) =>
|
||||
setState((current) => ({ ...current, sort_order: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center justify-between rounded-lg border p-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium'>Active option</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
Inactive options stay available for admin review but should disappear from normal
|
||||
dropdowns.
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={state.is_active}
|
||||
onCheckedChange={(checked) =>
|
||||
setState((current) => ({ ...current, is_active: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='master-option-metadata'>Metadata JSON</Label>
|
||||
<Textarea
|
||||
id='master-option-metadata'
|
||||
value={state.metadata}
|
||||
onChange={(event) =>
|
||||
setState((current) => ({ ...current, metadata: event.target.value }))
|
||||
}
|
||||
className='min-h-40 font-mono text-xs'
|
||||
/>
|
||||
{jsonError ? <p className='text-destructive text-xs'>{jsonError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button isLoading={pending} onClick={() => void handleSubmit()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,32 @@
|
||||
'use client';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user