task-j1
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
createApprovalWorkflowMutation,
|
||||
deleteApprovalWorkflowMutation,
|
||||
replaceApprovalWorkflowStepsMutation,
|
||||
updateApprovalWorkflowMutation
|
||||
} from '../mutations';
|
||||
import {
|
||||
approvalWorkflowByIdOptions,
|
||||
approvalWorkflowsQueryOptions
|
||||
} from '../queries';
|
||||
import type {
|
||||
ApprovalStepMutationPayload,
|
||||
ApprovalWorkflowDetail,
|
||||
ApprovalWorkflowMutationPayload
|
||||
} from '../types';
|
||||
|
||||
type WorkflowState = {
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type StepState = {
|
||||
stepNumber: string;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
isRequired: boolean;
|
||||
};
|
||||
|
||||
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowState {
|
||||
return {
|
||||
code: workflow?.code ?? '',
|
||||
name: workflow?.name ?? '',
|
||||
entityType: workflow?.entityType ?? 'quotation',
|
||||
isActive: workflow?.isActive ?? true
|
||||
};
|
||||
}
|
||||
|
||||
function toStepState(workflow?: ApprovalWorkflowDetail): StepState[] {
|
||||
return (
|
||||
workflow?.steps.map((step) => ({
|
||||
stepNumber: String(step.stepNumber),
|
||||
roleCode: step.roleCode,
|
||||
roleName: step.roleName,
|
||||
isRequired: step.isRequired
|
||||
})) ?? [{ stepNumber: '1', roleCode: 'sales_manager', roleName: 'Sales Manager', isRequired: true }]
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflow
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workflow?: ApprovalWorkflowDetail;
|
||||
}) {
|
||||
const [state, setState] = React.useState<WorkflowState>(toWorkflowState(workflow));
|
||||
const createMutation = useMutation({
|
||||
...createApprovalWorkflowMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Workflow created');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
...updateApprovalWorkflowMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Workflow updated');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setState(toWorkflowState(workflow));
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const payload: ApprovalWorkflowMutationPayload = {
|
||||
code: state.code,
|
||||
name: state.name,
|
||||
entityType: state.entityType,
|
||||
isActive: state.isActive
|
||||
};
|
||||
|
||||
if (workflow) {
|
||||
await updateMutation.mutateAsync({ id: workflow.id, values: payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{workflow ? 'Edit Workflow' : 'Create Workflow'}</DialogTitle>
|
||||
<DialogDescription>Sequential workflow only. No parallel or conditional logic is introduced here.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className='grid gap-4'>
|
||||
<Input placeholder='Workflow code' value={state.code} onChange={(e) => setState((current) => ({ ...current, code: e.target.value }))} />
|
||||
<Input placeholder='Workflow name' value={state.name} onChange={(e) => setState((current) => ({ ...current, name: e.target.value }))} />
|
||||
<Input placeholder='Entity type' value={state.entityType} onChange={(e) => setState((current) => ({ ...current, entityType: e.target.value }))} />
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Active</div>
|
||||
<div className='text-muted-foreground text-sm'>Inactive workflows stay available in audit history only.</div>
|
||||
</div>
|
||||
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' isLoading={isPending}>
|
||||
{workflow ? 'Save Workflow' : 'Create Workflow'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function StepsDialog({
|
||||
workflow,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
workflow: ApprovalWorkflowDetail;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [steps, setSteps] = React.useState<StepState[]>(toStepState(workflow));
|
||||
const replaceMutation = useMutation({
|
||||
...replaceApprovalWorkflowStepsMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Workflow steps updated');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setSteps(toStepState(workflow));
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
function updateStep(index: number, field: keyof StepState, value: string | boolean) {
|
||||
setSteps((current) =>
|
||||
current.map((step, stepIndex) =>
|
||||
stepIndex === index ? { ...step, [field]: value } : step
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const payload: ApprovalStepMutationPayload[] = steps.map((step, index) => ({
|
||||
stepNumber: Number(step.stepNumber || index + 1),
|
||||
roleCode: step.roleCode,
|
||||
roleName: step.roleName,
|
||||
isRequired: step.isRequired
|
||||
}));
|
||||
|
||||
await replaceMutation.mutateAsync({ workflowId: workflow.id, steps: payload });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-4xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Steps</DialogTitle>
|
||||
<DialogDescription>Each step resolves to a CRM business role and runs strictly in order.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className='space-y-4'>
|
||||
{steps.map((step, index) => (
|
||||
<div key={`${index}-${step.roleCode}`} className='grid gap-3 rounded-lg border p-4 md:grid-cols-4'>
|
||||
<Input placeholder='Step no.' value={step.stepNumber} onChange={(e) => updateStep(index, 'stepNumber', e.target.value)} />
|
||||
<Input placeholder='Role code' value={step.roleCode} onChange={(e) => updateStep(index, 'roleCode', e.target.value)} />
|
||||
<Input placeholder='Role name' value={step.roleName} onChange={(e) => updateStep(index, 'roleName', e.target.value)} />
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<span className='text-sm font-medium'>Required</span>
|
||||
<Switch checked={step.isRequired} onCheckedChange={(checked) => updateStep(index, 'isRequired', checked)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setSteps((current) => [
|
||||
...current,
|
||||
{
|
||||
stepNumber: String(current.length + 1),
|
||||
roleCode: '',
|
||||
roleName: '',
|
||||
isRequired: true
|
||||
}
|
||||
])
|
||||
}
|
||||
>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Add Step
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' isLoading={replaceMutation.isPending}>
|
||||
Save Steps
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowDetailActions({ workflowId }: { workflowId: string }) {
|
||||
const { data } = useSuspenseQuery(approvalWorkflowByIdOptions(workflowId));
|
||||
const workflow = data.workflow;
|
||||
const [workflowDialogOpen, setWorkflowDialogOpen] = React.useState(false);
|
||||
const [stepsDialogOpen, setStepsDialogOpen] = React.useState(false);
|
||||
const deleteMutation = useMutation({
|
||||
...deleteApprovalWorkflowMutation,
|
||||
onSuccess: () => toast.success('Workflow deleted'),
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex gap-2'>
|
||||
<Button variant='outline' onClick={() => setWorkflowDialogOpen(true)}>
|
||||
Edit Workflow
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => setStepsDialogOpen(true)}>
|
||||
Manage Steps
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => deleteMutation.mutate(workflow.id)} isLoading={deleteMutation.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className='mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||
{workflow.steps.map((step) => (
|
||||
<div key={step.id} className='rounded-lg border p-4'>
|
||||
<div className='font-medium'>Step {step.stepNumber}</div>
|
||||
<div className='text-muted-foreground mt-1 text-sm'>{step.roleName}</div>
|
||||
<div className='mt-2 flex gap-2'>
|
||||
<Badge variant='outline'>{step.roleCode}</Badge>
|
||||
<Badge variant={step.isRequired ? 'secondary' : 'outline'}>
|
||||
{step.isRequired ? 'Required' : 'Optional'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<WorkflowDialog open={workflowDialogOpen} onOpenChange={setWorkflowDialogOpen} workflow={workflow} />
|
||||
<StepsDialog open={stepsDialogOpen} onOpenChange={setStepsDialogOpen} workflow={workflow} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalWorkflowSettings() {
|
||||
const { data } = useSuspenseQuery(approvalWorkflowsQueryOptions());
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
||||
<div>
|
||||
<div className='font-medium'>Approval Workflow Admin</div>
|
||||
<div className='text-muted-foreground text-sm'>Configure sequential CRM approval workflows and business-role steps.</div>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Create Workflow
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.items.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No approval workflows configured.
|
||||
</div>
|
||||
) : (
|
||||
data.items.map((workflow) => (
|
||||
<div key={workflow.id} className='rounded-xl border p-5'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||
<div>
|
||||
<div className='text-xl font-semibold'>{workflow.name}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{workflow.code} • {workflow.entityType}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Badge variant={workflow.isActive ? 'secondary' : 'outline'}>
|
||||
{workflow.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{workflow.stepCount} steps</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<React.Suspense fallback={<div className='text-muted-foreground mt-4 text-sm'>Loading workflow detail...</div>}>
|
||||
<WorkflowDetailActions workflowId={workflow.id} />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<WorkflowDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,23 @@ import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
approveApproval,
|
||||
cancelApproval,
|
||||
createApprovalWorkflow,
|
||||
deleteApprovalWorkflow,
|
||||
rejectApproval,
|
||||
replaceApprovalWorkflowSteps,
|
||||
returnApproval,
|
||||
submitApproval,
|
||||
submitQuotationForApproval
|
||||
submitQuotationForApproval,
|
||||
updateApprovalWorkflow
|
||||
} from './service';
|
||||
import { approvalKeys } from './queries';
|
||||
import { approvalKeys, approvalWorkflowKeys } from './queries';
|
||||
import { quotationKeys } from '@/features/crm/quotations/api/queries';
|
||||
import type { ApprovalActionPayload, SubmitApprovalPayload } from './types';
|
||||
import type {
|
||||
ApprovalActionPayload,
|
||||
ApprovalStepMutationPayload,
|
||||
ApprovalWorkflowMutationPayload,
|
||||
SubmitApprovalPayload
|
||||
} from './types';
|
||||
|
||||
async function invalidateApprovalLists() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.lists() });
|
||||
@@ -21,6 +30,14 @@ async function invalidateApprovalDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
|
||||
}
|
||||
|
||||
async function invalidateApprovalWorkflows() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: approvalWorkflowKeys.lists() });
|
||||
}
|
||||
|
||||
async function invalidateApprovalWorkflowDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: approvalWorkflowKeys.detail(id) });
|
||||
}
|
||||
|
||||
async function invalidateQuotationQueries(quotationId: string) {
|
||||
const queryClient = getQueryClient();
|
||||
await Promise.all([
|
||||
@@ -114,3 +131,59 @@ export const returnApprovalMutation = mutationOptions({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createApprovalWorkflowMutation = mutationOptions({
|
||||
mutationFn: (data: ApprovalWorkflowMutationPayload) => createApprovalWorkflow(data),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateApprovalWorkflows();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const updateApprovalWorkflowMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
id,
|
||||
values
|
||||
}: {
|
||||
id: string;
|
||||
values: Partial<ApprovalWorkflowMutationPayload>;
|
||||
}) => updateApprovalWorkflow(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateApprovalWorkflows(),
|
||||
invalidateApprovalWorkflowDetail(variables.id)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteApprovalWorkflowMutation = mutationOptions({
|
||||
mutationFn: (id: string) => deleteApprovalWorkflow(id),
|
||||
onSettled: async (_data, error, id) => {
|
||||
if (!error) {
|
||||
const queryClient = getQueryClient();
|
||||
await invalidateApprovalWorkflows();
|
||||
queryClient.removeQueries({ queryKey: approvalWorkflowKeys.detail(id) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const replaceApprovalWorkflowStepsMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
workflowId,
|
||||
steps
|
||||
}: {
|
||||
workflowId: string;
|
||||
steps: ApprovalStepMutationPayload[];
|
||||
}) => replaceApprovalWorkflowSteps(workflowId, steps),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateApprovalWorkflows(),
|
||||
invalidateApprovalWorkflowDetail(variables.workflowId)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getApprovalById, getApprovals } from './service';
|
||||
import {
|
||||
getApprovalById,
|
||||
getApprovalWorkflowById,
|
||||
getApprovalWorkflows,
|
||||
getApprovals
|
||||
} from './service';
|
||||
import type { ApprovalFilters } from './types';
|
||||
|
||||
export const approvalKeys = {
|
||||
@@ -10,6 +15,14 @@ export const approvalKeys = {
|
||||
detail: (id: string) => [...approvalKeys.details(), id] as const
|
||||
};
|
||||
|
||||
export const approvalWorkflowKeys = {
|
||||
all: ['crm-approval-workflows'] as const,
|
||||
lists: () => [...approvalWorkflowKeys.all, 'list'] as const,
|
||||
list: () => [...approvalWorkflowKeys.lists(), 'all'] as const,
|
||||
details: () => [...approvalWorkflowKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...approvalWorkflowKeys.details(), id] as const
|
||||
};
|
||||
|
||||
export const approvalsQueryOptions = (filters: ApprovalFilters) =>
|
||||
queryOptions({
|
||||
queryKey: approvalKeys.list(filters),
|
||||
@@ -21,3 +34,15 @@ export const approvalByIdOptions = (id: string) =>
|
||||
queryKey: approvalKeys.detail(id),
|
||||
queryFn: () => getApprovalById(id)
|
||||
});
|
||||
|
||||
export const approvalWorkflowsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: approvalWorkflowKeys.list(),
|
||||
queryFn: () => getApprovalWorkflows()
|
||||
});
|
||||
|
||||
export const approvalWorkflowByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: approvalWorkflowKeys.detail(id),
|
||||
queryFn: () => getApprovalWorkflowById(id)
|
||||
});
|
||||
|
||||
@@ -19,9 +19,13 @@ import type {
|
||||
ApprovalDetailRecord,
|
||||
ApprovalFilters,
|
||||
ApprovalListItem,
|
||||
ApprovalStepMutationPayload,
|
||||
ApprovalRequestRecord,
|
||||
ApprovalStepRecord,
|
||||
ApprovalTimelineItem,
|
||||
ApprovalWorkflowDetail,
|
||||
ApprovalWorkflowListItem,
|
||||
ApprovalWorkflowMutationPayload,
|
||||
ApprovalWorkflowRecord,
|
||||
SubmitApprovalPayload
|
||||
} from '../types';
|
||||
@@ -170,6 +174,46 @@ async function assertWorkflowByCode(organizationId: string, code: string, entity
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async function assertWorkflowById(id: string, organizationId: string) {
|
||||
const [workflow] = await db
|
||||
.select()
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalWorkflows.id, id),
|
||||
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||
isNull(crmApprovalWorkflows.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!workflow) {
|
||||
throw new AuthError('Approval workflow not found', 404);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async function assertWorkflowStep(id: string, organizationId: string) {
|
||||
const [step] = await db
|
||||
.select()
|
||||
.from(crmApprovalSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalSteps.id, id),
|
||||
eq(crmApprovalSteps.organizationId, organizationId),
|
||||
isNull(crmApprovalSteps.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!step) {
|
||||
throw new AuthError('Approval workflow step not found', 404);
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
async function assertApprovalRequest(id: string, organizationId: string) {
|
||||
const [request] = await db
|
||||
.select()
|
||||
@@ -209,6 +253,204 @@ async function listWorkflowSteps(
|
||||
return rows.map(mapStepRecord);
|
||||
}
|
||||
|
||||
export async function listApprovalWorkflows(
|
||||
organizationId: string
|
||||
): Promise<ApprovalWorkflowListItem[]> {
|
||||
const [workflows, steps] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||
isNull(crmApprovalWorkflows.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmApprovalWorkflows.entityType), asc(crmApprovalWorkflows.code)),
|
||||
db
|
||||
.select()
|
||||
.from(crmApprovalSteps)
|
||||
.where(
|
||||
and(eq(crmApprovalSteps.organizationId, organizationId), isNull(crmApprovalSteps.deletedAt))
|
||||
)
|
||||
]);
|
||||
|
||||
return workflows.map((workflow) => ({
|
||||
...mapWorkflowRecord(workflow),
|
||||
stepCount: steps.filter((step) => step.workflowId === workflow.id).length
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getApprovalWorkflow(
|
||||
id: string,
|
||||
organizationId: string
|
||||
): Promise<ApprovalWorkflowDetail> {
|
||||
const workflow = await assertWorkflowById(id, organizationId);
|
||||
const steps = await listWorkflowSteps(id, organizationId);
|
||||
|
||||
return {
|
||||
...mapWorkflowRecord(workflow),
|
||||
steps
|
||||
};
|
||||
}
|
||||
|
||||
export async function createApprovalWorkflow(
|
||||
organizationId: string,
|
||||
payload: ApprovalWorkflowMutationPayload
|
||||
): Promise<ApprovalWorkflowRecord> {
|
||||
const [created] = await db
|
||||
.insert(crmApprovalWorkflows)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
code: payload.code.trim(),
|
||||
name: payload.name.trim(),
|
||||
entityType: payload.entityType.trim(),
|
||||
isActive: payload.isActive ?? true
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapWorkflowRecord(created);
|
||||
}
|
||||
|
||||
export async function updateApprovalWorkflow(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
payload: Partial<ApprovalWorkflowMutationPayload>
|
||||
): Promise<ApprovalWorkflowRecord> {
|
||||
const current = await assertWorkflowById(id, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmApprovalWorkflows)
|
||||
.set({
|
||||
code: payload.code?.trim() ?? current.code,
|
||||
name: payload.name?.trim() ?? current.name,
|
||||
entityType: payload.entityType?.trim() ?? current.entityType,
|
||||
isActive: payload.isActive ?? current.isActive,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalWorkflows.id, id))
|
||||
.returning();
|
||||
|
||||
return mapWorkflowRecord(updated);
|
||||
}
|
||||
|
||||
export async function softDeleteApprovalWorkflow(id: string, organizationId: string) {
|
||||
const current = await assertWorkflowById(id, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmApprovalWorkflows)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalWorkflows.id, id))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
before: current,
|
||||
after: updated
|
||||
};
|
||||
}
|
||||
|
||||
export async function createApprovalStep(
|
||||
workflowId: string,
|
||||
organizationId: string,
|
||||
payload: ApprovalStepMutationPayload
|
||||
): Promise<ApprovalStepRecord> {
|
||||
await assertWorkflowById(workflowId, organizationId);
|
||||
const [created] = await db
|
||||
.insert(crmApprovalSteps)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
workflowId,
|
||||
stepNumber: payload.stepNumber,
|
||||
roleCode: payload.roleCode.trim(),
|
||||
roleName: payload.roleName.trim(),
|
||||
isRequired: payload.isRequired ?? true
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapStepRecord(created);
|
||||
}
|
||||
|
||||
export async function updateApprovalStep(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
payload: Partial<ApprovalStepMutationPayload>
|
||||
): Promise<ApprovalStepRecord> {
|
||||
const current = await assertWorkflowStep(id, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmApprovalSteps)
|
||||
.set({
|
||||
stepNumber: payload.stepNumber ?? current.stepNumber,
|
||||
roleCode: payload.roleCode?.trim() ?? current.roleCode,
|
||||
roleName: payload.roleName?.trim() ?? current.roleName,
|
||||
isRequired: payload.isRequired ?? current.isRequired,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalSteps.id, id))
|
||||
.returning();
|
||||
|
||||
return mapStepRecord(updated);
|
||||
}
|
||||
|
||||
export async function deleteApprovalStep(id: string, organizationId: string) {
|
||||
const current = await assertWorkflowStep(id, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmApprovalSteps)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalSteps.id, id))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
before: current,
|
||||
after: updated
|
||||
};
|
||||
}
|
||||
|
||||
export async function replaceApprovalWorkflowSteps(
|
||||
workflowId: string,
|
||||
organizationId: string,
|
||||
steps: ApprovalStepMutationPayload[]
|
||||
): Promise<ApprovalStepRecord[]> {
|
||||
await assertWorkflowById(workflowId, organizationId);
|
||||
const existingSteps = await listWorkflowSteps(workflowId, organizationId);
|
||||
|
||||
if (existingSteps.length) {
|
||||
await db
|
||||
.update(crmApprovalSteps)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalSteps.workflowId, workflowId));
|
||||
}
|
||||
|
||||
if (!steps.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const created = await db
|
||||
.insert(crmApprovalSteps)
|
||||
.values(
|
||||
steps.map((step, index) => ({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
workflowId,
|
||||
stepNumber: step.stepNumber ?? index + 1,
|
||||
roleCode: step.roleCode.trim(),
|
||||
roleName: step.roleName.trim(),
|
||||
isRequired: step.isRequired ?? true
|
||||
}))
|
||||
)
|
||||
.returning();
|
||||
|
||||
return created.map(mapStepRecord);
|
||||
}
|
||||
|
||||
async function assertActivePendingRequestForEntity(
|
||||
organizationId: string,
|
||||
entityType: string,
|
||||
|
||||
@@ -4,6 +4,10 @@ import type {
|
||||
ApprovalDetailResponse,
|
||||
ApprovalFilters,
|
||||
ApprovalListResponse,
|
||||
ApprovalStepMutationPayload,
|
||||
ApprovalWorkflowDetailResponse,
|
||||
ApprovalWorkflowListResponse,
|
||||
ApprovalWorkflowMutationPayload,
|
||||
MutationSuccessResponse,
|
||||
SubmitApprovalPayload
|
||||
} from './types';
|
||||
@@ -67,3 +71,46 @@ export async function submitQuotationForApproval(id: string, remark?: string) {
|
||||
body: JSON.stringify({ remark })
|
||||
});
|
||||
}
|
||||
|
||||
const SETTINGS_BASE = '/crm/settings/approval-workflows';
|
||||
|
||||
export async function getApprovalWorkflows() {
|
||||
return apiClient<ApprovalWorkflowListResponse>(SETTINGS_BASE);
|
||||
}
|
||||
|
||||
export async function getApprovalWorkflowById(id: string) {
|
||||
return apiClient<ApprovalWorkflowDetailResponse>(`${SETTINGS_BASE}/${id}`);
|
||||
}
|
||||
|
||||
export async function createApprovalWorkflow(data: ApprovalWorkflowMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(SETTINGS_BASE, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateApprovalWorkflow(
|
||||
id: string,
|
||||
data: Partial<ApprovalWorkflowMutationPayload>
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteApprovalWorkflow(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceApprovalWorkflowSteps(
|
||||
workflowId: string,
|
||||
data: ApprovalStepMutationPayload[]
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ steps: data })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ export interface ApprovalStepRecord {
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord {
|
||||
stepCount: number;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord {
|
||||
steps: ApprovalStepRecord[];
|
||||
}
|
||||
|
||||
export interface ApprovalRequestRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -98,6 +106,20 @@ export interface ApprovalActionPayload {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowMutationPayload {
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalStepMutationPayload {
|
||||
stepNumber: number;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
@@ -115,6 +137,20 @@ export interface ApprovalDetailResponse {
|
||||
approval: ApprovalDetailRecord;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
items: ApprovalWorkflowListItem[];
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowDetailResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
workflow: ApprovalWorkflowDetail;
|
||||
}
|
||||
|
||||
export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
||||
53
src/features/foundation/document-sequence/client-service.ts
Normal file
53
src/features/foundation/document-sequence/client-service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
DocumentSequenceDetailResponse,
|
||||
DocumentSequenceListResponse,
|
||||
DocumentSequenceMutationPayload,
|
||||
DocumentSequencePreviewResponse,
|
||||
DocumentSequenceResetPayload,
|
||||
MutationSuccessResponse
|
||||
} from './types';
|
||||
|
||||
const BASE_PATH = '/crm/settings/document-sequences';
|
||||
|
||||
export async function getDocumentSequences() {
|
||||
return apiClient<DocumentSequenceListResponse>(BASE_PATH);
|
||||
}
|
||||
|
||||
export async function getDocumentSequenceById(id: string) {
|
||||
return apiClient<DocumentSequenceDetailResponse>(`${BASE_PATH}/${id}`);
|
||||
}
|
||||
|
||||
export async function createDocumentSequence(data: DocumentSequenceMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(BASE_PATH, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDocumentSequence(
|
||||
id: string,
|
||||
data: Partial<DocumentSequenceMutationPayload>
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDocumentSequence(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewDocumentSequence(id: string) {
|
||||
return apiClient<DocumentSequencePreviewResponse>(`${BASE_PATH}/${id}/preview`);
|
||||
}
|
||||
|
||||
export async function resetDocumentSequence(id: string, data: DocumentSequenceResetPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}/reset`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
createDocumentSequenceMutation,
|
||||
deleteDocumentSequenceMutation,
|
||||
resetDocumentSequenceMutation,
|
||||
updateDocumentSequenceMutation
|
||||
} from '../mutations';
|
||||
import { documentSequencesQueryOptions } from '../queries';
|
||||
import type { DocumentSequenceListItem, DocumentSequenceMutationPayload } from '../types';
|
||||
|
||||
type SequenceState = {
|
||||
documentType: string;
|
||||
prefix: string;
|
||||
period: string;
|
||||
branchId: string;
|
||||
currentNumber: string;
|
||||
paddingLength: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
function toState(sequence?: DocumentSequenceListItem): SequenceState {
|
||||
return {
|
||||
documentType: sequence?.documentType ?? 'quotation',
|
||||
prefix: sequence?.prefix ?? 'QT',
|
||||
period: sequence?.period ?? '',
|
||||
branchId: sequence?.branchId ?? '',
|
||||
currentNumber: String(sequence?.currentNumber ?? 0),
|
||||
paddingLength: String(sequence?.paddingLength ?? 3),
|
||||
isActive: sequence?.isActive ?? true
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(state: SequenceState): DocumentSequenceMutationPayload {
|
||||
return {
|
||||
documentType: state.documentType,
|
||||
prefix: state.prefix,
|
||||
period: state.period,
|
||||
branchId: state.branchId || null,
|
||||
currentNumber: Number(state.currentNumber || 0),
|
||||
paddingLength: Number(state.paddingLength || 3),
|
||||
isActive: state.isActive
|
||||
};
|
||||
}
|
||||
|
||||
function SequenceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sequence
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sequence?: DocumentSequenceListItem;
|
||||
}) {
|
||||
const [state, setState] = React.useState<SequenceState>(toState(sequence));
|
||||
const createMutation = useMutation({
|
||||
...createDocumentSequenceMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Sequence created');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
...updateDocumentSequenceMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Sequence updated');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setState(toState(sequence));
|
||||
}
|
||||
}, [open, sequence]);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const payload = buildPayload(state);
|
||||
|
||||
if (sequence) {
|
||||
await updateMutation.mutateAsync({ id: sequence.id, values: payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{sequence ? 'Edit Sequence' : 'Create Sequence'}</DialogTitle>
|
||||
<DialogDescription>Preview never increments the counter. Generation remains server-side only.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className='grid gap-4'>
|
||||
<Input placeholder='Document type' value={state.documentType} onChange={(e) => setState((current) => ({ ...current, documentType: e.target.value }))} />
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Input placeholder='Prefix' value={state.prefix} onChange={(e) => setState((current) => ({ ...current, prefix: e.target.value }))} />
|
||||
<Input placeholder='Period' value={state.period} onChange={(e) => setState((current) => ({ ...current, period: e.target.value }))} />
|
||||
</div>
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<Input placeholder='Branch ID (optional)' value={state.branchId} onChange={(e) => setState((current) => ({ ...current, branchId: e.target.value }))} />
|
||||
<Input placeholder='Current number' value={state.currentNumber} onChange={(e) => setState((current) => ({ ...current, currentNumber: e.target.value }))} />
|
||||
<Input placeholder='Padding length' value={state.paddingLength} onChange={(e) => setState((current) => ({ ...current, paddingLength: e.target.value }))} />
|
||||
</div>
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Active</div>
|
||||
<div className='text-muted-foreground text-sm'>Inactive sequences can stay preserved for legacy numbering.</div>
|
||||
</div>
|
||||
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' isLoading={isPending}>
|
||||
{sequence ? 'Save Sequence' : 'Create Sequence'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentSequenceSettings() {
|
||||
const { data } = useSuspenseQuery(documentSequencesQueryOptions());
|
||||
const [dialogState, setDialogState] = React.useState<{ open: boolean; sequence?: DocumentSequenceListItem }>({
|
||||
open: false
|
||||
});
|
||||
const resetMutation = useMutation({
|
||||
...resetDocumentSequenceMutation,
|
||||
onSuccess: () => toast.success('Sequence reset'),
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Reset failed')
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
...deleteDocumentSequenceMutation,
|
||||
onSuccess: () => toast.success('Sequence deleted'),
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
||||
<div>
|
||||
<div className='font-medium'>Document Sequence Admin</div>
|
||||
<div className='text-muted-foreground text-sm'>Maintain organization-scoped numbering strategy without rewriting historic document codes.</div>
|
||||
</div>
|
||||
<Button onClick={() => setDialogState({ open: true })}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Create Sequence
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.items.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No document sequences configured yet.
|
||||
</div>
|
||||
) : (
|
||||
data.items.map((sequence) => (
|
||||
<div key={sequence.id} className='rounded-xl border p-5'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||
<div>
|
||||
<div className='text-xl font-semibold'>{sequence.documentType}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{sequence.prefix}{sequence.period} • branch {sequence.branchId || 'default'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Badge variant={sequence.isActive ? 'secondary' : 'outline'}>
|
||||
{sequence.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Badge variant='outline'>Next {sequence.nextPreview}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-4 grid gap-4 md:grid-cols-4'>
|
||||
<div className='rounded-lg bg-muted/40 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Current Number</div>
|
||||
<div className='mt-1 text-sm font-medium'>{sequence.currentNumber}</div>
|
||||
</div>
|
||||
<div className='rounded-lg bg-muted/40 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Padding Length</div>
|
||||
<div className='mt-1 text-sm font-medium'>{sequence.paddingLength}</div>
|
||||
</div>
|
||||
<div className='rounded-lg bg-muted/40 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Updated At</div>
|
||||
<div className='mt-1 text-sm font-medium'>{new Date(sequence.updatedAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className='rounded-lg bg-muted/40 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Preview</div>
|
||||
<div className='mt-1 text-sm font-medium'>{sequence.nextPreview}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-4 flex flex-wrap gap-2'>
|
||||
<Button variant='outline' onClick={() => setDialogState({ open: true, sequence })}>
|
||||
Edit Sequence
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
resetMutation.mutate({
|
||||
id: sequence.id,
|
||||
values: { currentNumber: 0 }
|
||||
})
|
||||
}
|
||||
isLoading={resetMutation.isPending}
|
||||
>
|
||||
Reset Counter
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => deleteMutation.mutate(sequence.id)} isLoading={deleteMutation.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<SequenceDialog
|
||||
open={dialogState.open}
|
||||
onOpenChange={(open) => setDialogState((current) => ({ ...current, open }))}
|
||||
sequence={dialogState.sequence}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/features/foundation/document-sequence/mutations.ts
Normal file
68
src/features/foundation/document-sequence/mutations.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
createDocumentSequence,
|
||||
deleteDocumentSequence,
|
||||
resetDocumentSequence,
|
||||
updateDocumentSequence
|
||||
} from './client-service';
|
||||
import { documentSequenceKeys } from './queries';
|
||||
import type { DocumentSequenceMutationPayload, DocumentSequenceResetPayload } from './types';
|
||||
|
||||
async function invalidateDocumentSequences() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: documentSequenceKeys.lists() });
|
||||
}
|
||||
|
||||
async function invalidateDocumentSequenceDetail(id: string) {
|
||||
await Promise.all([
|
||||
getQueryClient().invalidateQueries({ queryKey: documentSequenceKeys.detail(id) }),
|
||||
getQueryClient().invalidateQueries({ queryKey: documentSequenceKeys.preview(id) })
|
||||
]);
|
||||
}
|
||||
|
||||
export const createDocumentSequenceMutation = mutationOptions({
|
||||
mutationFn: (data: DocumentSequenceMutationPayload) => createDocumentSequence(data),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateDocumentSequences();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const updateDocumentSequenceMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: Partial<DocumentSequenceMutationPayload> }) =>
|
||||
updateDocumentSequence(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateDocumentSequences(),
|
||||
invalidateDocumentSequenceDetail(variables.id)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteDocumentSequenceMutation = mutationOptions({
|
||||
mutationFn: (id: string) => deleteDocumentSequence(id),
|
||||
onSettled: async (_data, error, id) => {
|
||||
if (!error) {
|
||||
const queryClient = getQueryClient();
|
||||
await invalidateDocumentSequences();
|
||||
queryClient.removeQueries({ queryKey: documentSequenceKeys.detail(id) });
|
||||
queryClient.removeQueries({ queryKey: documentSequenceKeys.preview(id) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const resetDocumentSequenceMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: DocumentSequenceResetPayload }) =>
|
||||
resetDocumentSequence(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateDocumentSequences(),
|
||||
invalidateDocumentSequenceDetail(variables.id)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
34
src/features/foundation/document-sequence/queries.ts
Normal file
34
src/features/foundation/document-sequence/queries.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import {
|
||||
getDocumentSequenceById,
|
||||
getDocumentSequences,
|
||||
previewDocumentSequence
|
||||
} from './client-service';
|
||||
|
||||
export const documentSequenceKeys = {
|
||||
all: ['crm-document-sequences'] as const,
|
||||
lists: () => [...documentSequenceKeys.all, 'list'] as const,
|
||||
list: () => [...documentSequenceKeys.lists(), 'all'] as const,
|
||||
details: () => [...documentSequenceKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...documentSequenceKeys.details(), id] as const,
|
||||
previewRoot: () => [...documentSequenceKeys.all, 'preview'] as const,
|
||||
preview: (id: string) => [...documentSequenceKeys.previewRoot(), id] as const
|
||||
};
|
||||
|
||||
export const documentSequencesQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: documentSequenceKeys.list(),
|
||||
queryFn: () => getDocumentSequences()
|
||||
});
|
||||
|
||||
export const documentSequenceByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: documentSequenceKeys.detail(id),
|
||||
queryFn: () => getDocumentSequenceById(id)
|
||||
});
|
||||
|
||||
export const documentSequencePreviewOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: documentSequenceKeys.preview(id),
|
||||
queryFn: () => previewDocumentSequence(id)
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { documentSequences } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
|
||||
import type { DocumentSequenceInput, DocumentSequenceResult } from './types';
|
||||
import type {
|
||||
DocumentSequenceInput,
|
||||
DocumentSequenceListItem,
|
||||
DocumentSequenceMutationPayload,
|
||||
DocumentSequenceRecord,
|
||||
DocumentSequenceResetPayload,
|
||||
DocumentSequenceResult
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
|
||||
customer: 'CUS',
|
||||
@@ -28,6 +36,38 @@ function buildDocumentCode(
|
||||
return `${prefix}${period}-${String(nextNumber).padStart(paddingLength, '0')}`;
|
||||
}
|
||||
|
||||
function normalizeBranchId(branchId?: string | null) {
|
||||
return branchId?.trim() || '';
|
||||
}
|
||||
|
||||
function mapSequenceRecord(row: typeof documentSequences.$inferSelect): DocumentSequenceRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
branchId: row.branchId || null,
|
||||
documentType: row.documentType,
|
||||
prefix: row.prefix,
|
||||
period: row.period,
|
||||
currentNumber: row.currentNumber,
|
||||
paddingLength: row.paddingLength,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function mapSequenceListItem(row: typeof documentSequences.$inferSelect): DocumentSequenceListItem {
|
||||
return {
|
||||
...mapSequenceRecord(row),
|
||||
nextPreview: buildDocumentCode(
|
||||
row.prefix,
|
||||
row.period,
|
||||
row.currentNumber + 1,
|
||||
row.paddingLength
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveOrganizationId(organizationId?: string) {
|
||||
if (organizationId) {
|
||||
return organizationId;
|
||||
@@ -42,6 +82,22 @@ async function resolveOrganizationId(organizationId?: string) {
|
||||
return organization.id;
|
||||
}
|
||||
|
||||
async function assertSequence(id: string, organizationId: string) {
|
||||
const [sequence] = await db
|
||||
.select()
|
||||
.from(documentSequences)
|
||||
.where(
|
||||
and(eq(documentSequences.id, id), eq(documentSequences.organizationId, organizationId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!sequence) {
|
||||
throw new AuthError('Document sequence not found', 404);
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
async function ensureSequence(
|
||||
organizationId: string,
|
||||
documentType: string,
|
||||
@@ -95,12 +151,116 @@ function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentS
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDocumentSequences(organizationId: string): Promise<DocumentSequenceListItem[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(documentSequences)
|
||||
.where(eq(documentSequences.organizationId, organizationId))
|
||||
.orderBy(
|
||||
asc(documentSequences.documentType),
|
||||
asc(documentSequences.period),
|
||||
asc(documentSequences.branchId)
|
||||
);
|
||||
|
||||
return rows.map(mapSequenceListItem);
|
||||
}
|
||||
|
||||
export async function getDocumentSequence(id: string, organizationId: string): Promise<DocumentSequenceRecord> {
|
||||
const row = await assertSequence(id, organizationId);
|
||||
return mapSequenceRecord(row);
|
||||
}
|
||||
|
||||
export async function createDocumentSequence(
|
||||
organizationId: string,
|
||||
payload: DocumentSequenceMutationPayload
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const branchId = normalizeBranchId(payload.branchId);
|
||||
const [created] = await db
|
||||
.insert(documentSequences)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
branchId,
|
||||
documentType: payload.documentType.trim(),
|
||||
prefix: payload.prefix.trim(),
|
||||
period: payload.period.trim(),
|
||||
currentNumber: payload.currentNumber ?? 0,
|
||||
paddingLength: payload.paddingLength,
|
||||
isActive: payload.isActive ?? true,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapSequenceRecord(created);
|
||||
}
|
||||
|
||||
export async function updateDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
payload: Partial<DocumentSequenceMutationPayload>
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
const current = await assertSequence(id, organizationId);
|
||||
const [updated] = await db
|
||||
.update(documentSequences)
|
||||
.set({
|
||||
documentType: payload.documentType?.trim() ?? current.documentType,
|
||||
prefix: payload.prefix?.trim() ?? current.prefix,
|
||||
period: payload.period?.trim() ?? current.period,
|
||||
branchId:
|
||||
payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId),
|
||||
currentNumber: payload.currentNumber ?? current.currentNumber,
|
||||
paddingLength: payload.paddingLength ?? current.paddingLength,
|
||||
isActive: payload.isActive ?? current.isActive,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(documentSequences.id, id))
|
||||
.returning();
|
||||
|
||||
return mapSequenceRecord(updated);
|
||||
}
|
||||
|
||||
export async function resetDocumentSequence(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
payload: DocumentSequenceResetPayload
|
||||
): Promise<DocumentSequenceRecord> {
|
||||
await assertSequence(id, organizationId);
|
||||
const [updated] = await db
|
||||
.update(documentSequences)
|
||||
.set({
|
||||
currentNumber: payload.currentNumber,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(documentSequences.id, id))
|
||||
.returning();
|
||||
|
||||
return mapSequenceRecord(updated);
|
||||
}
|
||||
|
||||
export async function deleteDocumentSequence(id: string, organizationId: string): Promise<DocumentSequenceRecord> {
|
||||
await assertSequence(id, organizationId);
|
||||
const [deleted] = await db
|
||||
.delete(documentSequences)
|
||||
.where(eq(documentSequences.id, id))
|
||||
.returning();
|
||||
|
||||
return mapSequenceRecord(deleted);
|
||||
}
|
||||
|
||||
export async function previewDocumentSequenceById(
|
||||
id: string,
|
||||
organizationId: string
|
||||
): Promise<DocumentSequenceResult> {
|
||||
const sequence = await assertSequence(id, organizationId);
|
||||
return toSequenceResult(sequence);
|
||||
}
|
||||
|
||||
export async function previewNextDocumentCode(
|
||||
input: DocumentSequenceInput
|
||||
): Promise<DocumentSequenceResult> {
|
||||
const organizationId = await resolveOrganizationId(input.organizationId);
|
||||
const period = input.period ?? getCurrentPeriod();
|
||||
const branchId = input.branchId ?? '';
|
||||
const branchId = normalizeBranchId(input.branchId);
|
||||
const sequence = await ensureSequence(organizationId, input.documentType, period, branchId);
|
||||
|
||||
return toSequenceResult(sequence);
|
||||
@@ -111,7 +271,7 @@ export async function generateNextDocumentCode(
|
||||
): Promise<DocumentSequenceResult> {
|
||||
const organizationId = await resolveOrganizationId(input.organizationId);
|
||||
const period = input.period ?? getCurrentPeriod();
|
||||
const branchId = input.branchId ?? '';
|
||||
const branchId = normalizeBranchId(input.branchId);
|
||||
|
||||
await ensureSequence(organizationId, input.documentType, period, branchId);
|
||||
|
||||
|
||||
@@ -5,6 +5,20 @@ export interface DocumentSequenceInput {
|
||||
period?: string;
|
||||
}
|
||||
|
||||
export interface DocumentSequenceRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
branchId: string | null;
|
||||
documentType: string;
|
||||
prefix: string;
|
||||
period: string;
|
||||
currentNumber: number;
|
||||
paddingLength: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DocumentSequenceResult {
|
||||
code: string;
|
||||
documentType: string;
|
||||
@@ -14,3 +28,47 @@ export interface DocumentSequenceResult {
|
||||
period: string;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export interface DocumentSequenceListItem extends DocumentSequenceRecord {
|
||||
nextPreview: string;
|
||||
}
|
||||
|
||||
export interface DocumentSequenceListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
items: DocumentSequenceListItem[];
|
||||
}
|
||||
|
||||
export interface DocumentSequenceDetailResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
sequence: DocumentSequenceRecord;
|
||||
}
|
||||
|
||||
export interface DocumentSequencePreviewResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
preview: DocumentSequenceResult;
|
||||
}
|
||||
|
||||
export interface DocumentSequenceMutationPayload {
|
||||
documentType: string;
|
||||
prefix: string;
|
||||
period: string;
|
||||
branchId?: string | null;
|
||||
currentNumber?: number;
|
||||
paddingLength: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface DocumentSequenceResetPayload {
|
||||
currentNumber: number;
|
||||
}
|
||||
|
||||
export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,733 @@
|
||||
'use client';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import * as React from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { documentTemplatesQueryOptions } from '../queries';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
createDocumentTemplateMappingMutation,
|
||||
createDocumentTemplateMutation,
|
||||
createDocumentTemplateVersionMutation,
|
||||
deleteDocumentTemplateMappingMutation,
|
||||
updateDocumentTemplateMappingMutation,
|
||||
updateDocumentTemplateMutation,
|
||||
updateDocumentTemplateVersionMutation
|
||||
} from '../mutations';
|
||||
import { documentTemplateByIdOptions, documentTemplatesQueryOptions } from '../queries';
|
||||
import type {
|
||||
DocumentTemplateDetail,
|
||||
DocumentTemplateMappingMutationPayload,
|
||||
DocumentTemplateMutationPayload,
|
||||
DocumentTemplateVersionDetail,
|
||||
DocumentTemplateVersionMutationPayload
|
||||
} from '../types';
|
||||
|
||||
type TemplateFormState = {
|
||||
documentType: string;
|
||||
productType: string;
|
||||
fileType: string;
|
||||
templateName: string;
|
||||
description: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type VersionFormState = {
|
||||
version: string;
|
||||
filePath: string;
|
||||
schemaJson: string;
|
||||
previewImageUrl: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type MappingColumnState = {
|
||||
columnName: string;
|
||||
sourceField: string;
|
||||
columnLetter: string;
|
||||
sortOrder: string;
|
||||
formatMask: string;
|
||||
};
|
||||
|
||||
type MappingFormState = {
|
||||
placeholderKey: string;
|
||||
sourcePath: string;
|
||||
dataType: 'scalar' | 'multiline' | 'table' | 'image';
|
||||
sheetName: string;
|
||||
defaultValue: string;
|
||||
formatMask: string;
|
||||
sortOrder: string;
|
||||
columns: MappingColumnState[];
|
||||
};
|
||||
|
||||
function toTemplateState(template?: DocumentTemplateDetail): TemplateFormState {
|
||||
return {
|
||||
documentType: template?.documentType ?? 'quotation',
|
||||
productType: template?.productType ?? 'default',
|
||||
fileType: template?.fileType ?? 'pdfme',
|
||||
templateName: template?.templateName ?? '',
|
||||
description: template?.description ?? '',
|
||||
isDefault: template?.isDefault ?? false,
|
||||
isActive: template?.isActive ?? true
|
||||
};
|
||||
}
|
||||
|
||||
function toVersionState(version?: DocumentTemplateVersionDetail): VersionFormState {
|
||||
return {
|
||||
version: version?.version ?? '',
|
||||
filePath: version?.filePath ?? '',
|
||||
schemaJson: version ? JSON.stringify(version.schemaJson, null, 2) : '{}',
|
||||
previewImageUrl: version?.previewImageUrl ?? '',
|
||||
isActive: version?.isActive ?? true
|
||||
};
|
||||
}
|
||||
|
||||
function toMappingState(mapping?: DocumentTemplateVersionDetail['mappings'][number]): MappingFormState {
|
||||
return {
|
||||
placeholderKey: mapping?.placeholderKey ?? '',
|
||||
sourcePath: mapping?.sourcePath ?? '',
|
||||
dataType: mapping?.dataType ?? 'scalar',
|
||||
sheetName: mapping?.sheetName ?? '',
|
||||
defaultValue: mapping?.defaultValue ?? '',
|
||||
formatMask: mapping?.formatMask ?? '',
|
||||
sortOrder: String(mapping?.sortOrder ?? 0),
|
||||
columns:
|
||||
mapping?.columns.map((column) => ({
|
||||
columnName: column.columnName,
|
||||
sourceField: column.sourceField,
|
||||
columnLetter: column.columnLetter ?? '',
|
||||
sortOrder: String(column.sortOrder),
|
||||
formatMask: column.formatMask ?? ''
|
||||
})) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
function buildTemplatePayload(state: TemplateFormState): DocumentTemplateMutationPayload {
|
||||
return {
|
||||
documentType: state.documentType,
|
||||
productType: state.productType,
|
||||
fileType: state.fileType,
|
||||
templateName: state.templateName,
|
||||
description: state.description || null,
|
||||
isDefault: state.isDefault,
|
||||
isActive: state.isActive
|
||||
};
|
||||
}
|
||||
|
||||
function buildVersionPayload(state: VersionFormState): DocumentTemplateVersionMutationPayload {
|
||||
let schemaJson: unknown;
|
||||
|
||||
try {
|
||||
schemaJson = JSON.parse(state.schemaJson);
|
||||
} catch {
|
||||
throw new Error('Schema JSON must be valid JSON');
|
||||
}
|
||||
|
||||
return {
|
||||
version: state.version,
|
||||
filePath: state.filePath || null,
|
||||
schemaJson,
|
||||
previewImageUrl: state.previewImageUrl || null,
|
||||
isActive: state.isActive
|
||||
};
|
||||
}
|
||||
|
||||
function buildMappingPayload(state: MappingFormState): DocumentTemplateMappingMutationPayload {
|
||||
return {
|
||||
placeholderKey: state.placeholderKey,
|
||||
sourcePath: state.sourcePath,
|
||||
dataType: state.dataType,
|
||||
sheetName: state.sheetName || null,
|
||||
defaultValue: state.defaultValue || null,
|
||||
formatMask: state.formatMask || null,
|
||||
sortOrder: Number(state.sortOrder || 0),
|
||||
columns:
|
||||
state.dataType === 'table'
|
||||
? state.columns.map((column) => ({
|
||||
columnName: column.columnName,
|
||||
sourceField: column.sourceField,
|
||||
columnLetter: column.columnLetter || null,
|
||||
sortOrder: Number(column.sortOrder || 0),
|
||||
formatMask: column.formatMask || null
|
||||
}))
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
template
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
template?: DocumentTemplateDetail;
|
||||
}) {
|
||||
const [state, setState] = React.useState<TemplateFormState>(toTemplateState(template));
|
||||
const createMutation = useMutation({
|
||||
...createDocumentTemplateMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Template created');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
...updateDocumentTemplateMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Template updated');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setState(toTemplateState(template));
|
||||
}
|
||||
}, [open, template]);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const payload = buildTemplatePayload(state);
|
||||
|
||||
if (template) {
|
||||
await updateMutation.mutateAsync({ id: template.id, values: payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{template ? 'Edit Template' : 'Create Template'}</DialogTitle>
|
||||
<DialogDescription>Manage template metadata for CRM document generation.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className='grid gap-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Field label='Template Name'>
|
||||
<Input value={state.templateName} onChange={(e) => setState((current) => ({ ...current, templateName: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label='Document Type'>
|
||||
<Input value={state.documentType} onChange={(e) => setState((current) => ({ ...current, documentType: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label='Product Type'>
|
||||
<Input value={state.productType} onChange={(e) => setState((current) => ({ ...current, productType: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label='File Type'>
|
||||
<Input value={state.fileType} onChange={(e) => setState((current) => ({ ...current, fileType: e.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label='Description'>
|
||||
<Textarea value={state.description} onChange={(e) => setState((current) => ({ ...current, description: e.target.value }))} />
|
||||
</Field>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Default Template</div>
|
||||
<div className='text-muted-foreground text-sm'>Use this template as the default selection.</div>
|
||||
</div>
|
||||
<Switch checked={state.isDefault} onCheckedChange={(checked) => setState((current) => ({ ...current, isDefault: checked }))} />
|
||||
</div>
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Active</div>
|
||||
<div className='text-muted-foreground text-sm'>Inactive templates stay visible for audit history.</div>
|
||||
</div>
|
||||
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' isLoading={isPending}>
|
||||
{template ? 'Save Changes' : 'Create Template'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
templateId,
|
||||
version
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templateId: string;
|
||||
version?: DocumentTemplateVersionDetail;
|
||||
}) {
|
||||
const [state, setState] = React.useState<VersionFormState>(toVersionState(version));
|
||||
const createMutation = useMutation({
|
||||
...createDocumentTemplateVersionMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Version saved');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Save failed')
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
...updateDocumentTemplateVersionMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Version updated');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setState(toVersionState(version));
|
||||
}
|
||||
}, [open, version]);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const payload = buildVersionPayload(state);
|
||||
|
||||
if (version) {
|
||||
await updateMutation.mutateAsync({ templateId, versionId: version.id, values: payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync({ id: templateId, values: payload });
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-3xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{version ? 'Edit Version' : 'Create Version'}</DialogTitle>
|
||||
<DialogDescription>Schema JSON is accepted as raw JSON in this admin release.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className='grid gap-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Field label='Version'>
|
||||
<Input value={state.version} onChange={(e) => setState((current) => ({ ...current, version: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label='File Path'>
|
||||
<Input value={state.filePath} onChange={(e) => setState((current) => ({ ...current, filePath: e.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label='Preview Image URL'>
|
||||
<Input value={state.previewImageUrl} onChange={(e) => setState((current) => ({ ...current, previewImageUrl: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label='Schema JSON'>
|
||||
<Textarea className='min-h-64 font-mono text-xs' value={state.schemaJson} onChange={(e) => setState((current) => ({ ...current, schemaJson: e.target.value }))} />
|
||||
</Field>
|
||||
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Active Version</div>
|
||||
<div className='text-muted-foreground text-sm'>Inactive versions remain stored for traceability.</div>
|
||||
</div>
|
||||
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' isLoading={isPending}>
|
||||
{version ? 'Save Version' : 'Create Version'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MappingDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
templateId,
|
||||
version,
|
||||
mapping
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templateId: string;
|
||||
version: DocumentTemplateVersionDetail;
|
||||
mapping?: DocumentTemplateVersionDetail['mappings'][number];
|
||||
}) {
|
||||
const [state, setState] = React.useState<MappingFormState>(toMappingState(mapping));
|
||||
const createMutation = useMutation({
|
||||
...createDocumentTemplateMappingMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Mapping saved');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Save failed')
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
...updateDocumentTemplateMappingMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Mapping updated');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setState(toMappingState(mapping));
|
||||
}
|
||||
}, [open, mapping]);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const payload = buildMappingPayload(state);
|
||||
|
||||
if (mapping) {
|
||||
await updateMutation.mutateAsync({ templateId, mappingId: mapping.id, values: payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync({ templateId, versionId: version.id, values: payload });
|
||||
}
|
||||
|
||||
function updateColumn(index: number, field: keyof MappingColumnState, value: string) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
columns: current.columns.map((column, columnIndex) =>
|
||||
columnIndex === index ? { ...column, [field]: value } : column
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-4xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mapping ? 'Edit Mapping' : 'Create Mapping'}</DialogTitle>
|
||||
<DialogDescription>Table mappings can define per-column source fields and format masks.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className='grid gap-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<Field label='Placeholder Key'>
|
||||
<Input value={state.placeholderKey} onChange={(e) => setState((current) => ({ ...current, placeholderKey: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label='Source Path'>
|
||||
<Input value={state.sourcePath} onChange={(e) => setState((current) => ({ ...current, sourcePath: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label='Data Type'>
|
||||
<Input value={state.dataType} onChange={(e) => setState((current) => ({ ...current, dataType: e.target.value as MappingFormState['dataType'] }))} />
|
||||
</Field>
|
||||
<Field label='Sheet Name'>
|
||||
<Input value={state.sheetName} onChange={(e) => setState((current) => ({ ...current, sheetName: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label='Default Value'>
|
||||
<Input value={state.defaultValue} onChange={(e) => setState((current) => ({ ...current, defaultValue: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label='Format Mask'>
|
||||
<Input value={state.formatMask} onChange={(e) => setState((current) => ({ ...current, formatMask: e.target.value }))} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label='Sort Order'>
|
||||
<Input value={state.sortOrder} onChange={(e) => setState((current) => ({ ...current, sortOrder: e.target.value }))} />
|
||||
</Field>
|
||||
{state.dataType === 'table' ? (
|
||||
<div className='space-y-3 rounded-lg border p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='font-medium'>Table Columns</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setState((current) => ({
|
||||
...current,
|
||||
columns: [
|
||||
...current.columns,
|
||||
{
|
||||
columnName: '',
|
||||
sourceField: '',
|
||||
columnLetter: '',
|
||||
sortOrder: String(current.columns.length),
|
||||
formatMask: ''
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Add Column
|
||||
</Button>
|
||||
</div>
|
||||
{state.columns.map((column, index) => (
|
||||
<div key={`${index}-${column.columnName}`} className='grid gap-3 md:grid-cols-5'>
|
||||
<Input placeholder='Column name' value={column.columnName} onChange={(e) => updateColumn(index, 'columnName', e.target.value)} />
|
||||
<Input placeholder='Source field' value={column.sourceField} onChange={(e) => updateColumn(index, 'sourceField', e.target.value)} />
|
||||
<Input placeholder='Column letter' value={column.columnLetter} onChange={(e) => updateColumn(index, 'columnLetter', e.target.value)} />
|
||||
<Input placeholder='Sort order' value={column.sortOrder} onChange={(e) => updateColumn(index, 'sortOrder', e.target.value)} />
|
||||
<Input placeholder='Format mask' value={column.formatMask} onChange={(e) => updateColumn(index, 'formatMask', e.target.value)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' isLoading={isPending}>
|
||||
{mapping ? 'Save Mapping' : 'Create Mapping'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateDetailCard({ templateId }: { templateId: string }) {
|
||||
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
|
||||
const template = data.template;
|
||||
const [versionDialogOpen, setVersionDialogOpen] = React.useState(false);
|
||||
const [mappingDialogState, setMappingDialogState] = React.useState<{
|
||||
open: boolean;
|
||||
version?: DocumentTemplateVersionDetail;
|
||||
mapping?: DocumentTemplateVersionDetail['mappings'][number];
|
||||
}>({ open: false });
|
||||
const deleteMappingMutation = useMutation({
|
||||
...deleteDocumentTemplateMappingMutation,
|
||||
onSuccess: () => toast.success('Mapping deleted'),
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-4'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Document Type</div>
|
||||
<div className='mt-1 font-medium'>{template.documentType}</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Product Type</div>
|
||||
<div className='mt-1 font-medium'>{template.productType}</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Version Count</div>
|
||||
<div className='mt-1 font-medium'>{template.versions.length}</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Mapping Count</div>
|
||||
<div className='mt-1 font-medium'>{template.mappingCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={template.versions[0]?.id ?? 'empty'} className='space-y-4'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<TabsList className='flex flex-wrap'>
|
||||
{template.versions.map((version) => (
|
||||
<TabsTrigger key={version.id} value={version.id}>
|
||||
{version.version}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<Button onClick={() => setVersionDialogOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Add Version
|
||||
</Button>
|
||||
</div>
|
||||
{template.versions.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
|
||||
No versions created yet.
|
||||
</div>
|
||||
) : null}
|
||||
{template.versions.map((version) => (
|
||||
<TabsContent key={version.id} value={version.id} className='space-y-4'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div>
|
||||
<div className='font-medium'>Version {version.version}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{version.filePath || 'No file path set'}{version.previewImageUrl ? ` • ${version.previewImageUrl}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant={version.isActive ? 'secondary' : 'outline'}>
|
||||
{version.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Button variant='outline' onClick={() => setVersionDialogOpen(true)}>
|
||||
Edit Version
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setMappingDialogState({
|
||||
open: true,
|
||||
version
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Add Mapping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className='bg-muted mt-4 max-h-72 overflow-auto rounded-lg p-4 text-xs'>
|
||||
{JSON.stringify(version.schemaJson, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className='space-y-3'>
|
||||
{version.mappings.map((mapping) => (
|
||||
<div key={mapping.id} className='rounded-lg border p-4'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-3'>
|
||||
<div>
|
||||
<div className='font-medium'>{mapping.placeholderKey}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{mapping.sourcePath} • {mapping.dataType}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setMappingDialogState({
|
||||
open: true,
|
||||
version,
|
||||
mapping
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
deleteMappingMutation.mutate({
|
||||
templateId,
|
||||
mappingId: mapping.id
|
||||
})
|
||||
}
|
||||
isLoading={deleteMappingMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{mapping.columns.length ? (
|
||||
<div className='mt-3 rounded-lg border p-3'>
|
||||
<div className='mb-2 text-sm font-medium'>Table Columns</div>
|
||||
<div className='space-y-2 text-sm'>
|
||||
{mapping.columns.map((column) => (
|
||||
<div key={column.id} className='flex flex-wrap gap-2'>
|
||||
<Badge variant='outline'>{column.columnName}</Badge>
|
||||
<span>{column.sourceField}</span>
|
||||
{column.columnLetter ? <span>({column.columnLetter})</span> : null}
|
||||
{column.formatMask ? <span>• {column.formatMask}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{version.mappings.length === 0 ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
|
||||
No mappings defined for this version yet.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<VersionDialog
|
||||
open={versionDialogOpen}
|
||||
onOpenChange={setVersionDialogOpen}
|
||||
templateId={template.id}
|
||||
version={undefined}
|
||||
/>
|
||||
{mappingDialogState.open && mappingDialogState.version ? (
|
||||
<MappingDialog
|
||||
open={mappingDialogState.open}
|
||||
onOpenChange={(open) => setMappingDialogState((current) => ({ ...current, open }))}
|
||||
templateId={template.id}
|
||||
version={mappingDialogState.version}
|
||||
mapping={mappingDialogState.mapping}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplateSettings() {
|
||||
const { data } = useSuspenseQuery(
|
||||
documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' })
|
||||
);
|
||||
const [templateDialogState, setTemplateDialogState] = React.useState<{
|
||||
open: boolean;
|
||||
templateId?: string;
|
||||
}>({ open: false });
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
||||
<div>
|
||||
<div className='font-medium'>Template Configuration Center</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Admins can manage metadata, versions, mappings, and JSON schema without a visual designer.
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setTemplateDialogState({ open: true })}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
Create Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!data.items.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No production document templates found.
|
||||
</div>
|
||||
) : (
|
||||
data.items.map((template) => (
|
||||
<Card key={template.id}>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div key={template.id} className='space-y-4 rounded-xl border p-5'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||
<div className='space-y-1'>
|
||||
<CardTitle>{template.templateName}</CardTitle>
|
||||
<CardDescription>{template.description || 'No description provided.'}</CardDescription>
|
||||
<div className='text-xl font-semibold'>{template.templateName}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{template.description || 'No description provided.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{template.isDefault ? <Badge>Default</Badge> : null}
|
||||
@@ -30,33 +735,78 @@ export function TemplateSettings() {
|
||||
{template.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{template.fileType}</Badge>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setTemplateDialogState({ open: true, templateId: template.id })}
|
||||
>
|
||||
Edit Metadata
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
|
||||
<div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-5'>
|
||||
<div className='rounded-lg bg-muted/40 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Document Type</div>
|
||||
<div className='text-sm font-medium'>{template.documentType}</div>
|
||||
<div className='mt-1 text-sm font-medium'>{template.documentType}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='rounded-lg bg-muted/40 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Product Type</div>
|
||||
<div className='text-sm font-medium'>{template.productType}</div>
|
||||
<div className='mt-1 text-sm font-medium'>{template.productType}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='rounded-lg bg-muted/40 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Latest Version</div>
|
||||
<div className='text-sm font-medium'>{template.latestVersion ?? '-'}</div>
|
||||
<div className='mt-1 text-sm font-medium'>{template.latestVersion ?? '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='rounded-lg bg-muted/40 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Version Count</div>
|
||||
<div className='text-sm font-medium'>{template.versionCount}</div>
|
||||
<div className='mt-1 text-sm font-medium'>{template.versionCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='rounded-lg bg-muted/40 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>Mapping Count</div>
|
||||
<div className='text-sm font-medium'>{template.mappingCount}</div>
|
||||
<div className='mt-1 text-sm font-medium'>{template.mappingCount}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
|
||||
Loading template detail...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TemplateDetailCard templateId={template.id} />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<TemplateDialog
|
||||
open={templateDialogState.open && !templateDialogState.templateId}
|
||||
onOpenChange={(open) => setTemplateDialogState((current) => ({ ...current, open }))}
|
||||
/>
|
||||
{templateDialogState.open && templateDialogState.templateId ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<TemplateDetailDialog
|
||||
templateId={templateDialogState.templateId}
|
||||
open={templateDialogState.open}
|
||||
onOpenChange={(open) => setTemplateDialogState((current) => ({ ...current, open }))}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateDetailDialog({
|
||||
templateId,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
templateId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
|
||||
|
||||
return <TemplateDialog open={open} onOpenChange={onOpenChange} template={data.template} />;
|
||||
}
|
||||
|
||||
@@ -2,30 +2,38 @@ import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
createDocumentTemplate,
|
||||
createDocumentTemplateMapping,
|
||||
createDocumentTemplateVersion,
|
||||
deleteDocumentTemplate,
|
||||
updateDocumentTemplate
|
||||
deleteDocumentTemplateMapping,
|
||||
setDocumentTemplateVersionActive,
|
||||
updateDocumentTemplate,
|
||||
updateDocumentTemplateMapping,
|
||||
updateDocumentTemplateVersion
|
||||
} from './service';
|
||||
import { documentTemplateKeys } from './queries';
|
||||
import type { DocumentTemplateMutationPayload, DocumentTemplateVersionMutationPayload } from './types';
|
||||
import type {
|
||||
DocumentTemplateMappingMutationPayload,
|
||||
DocumentTemplateMutationPayload,
|
||||
DocumentTemplateVersionMutationPayload
|
||||
} from './types';
|
||||
|
||||
async function invalidateDocumentTemplateLists() {
|
||||
async function invalidateTemplateLists() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.lists() });
|
||||
}
|
||||
|
||||
async function invalidateDocumentTemplateDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(id) });
|
||||
}
|
||||
|
||||
async function invalidateDocumentTemplateVersions(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) });
|
||||
async function invalidateTemplateDetail(id: string) {
|
||||
await Promise.all([
|
||||
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(id) }),
|
||||
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) })
|
||||
]);
|
||||
}
|
||||
|
||||
export const createDocumentTemplateMutation = mutationOptions({
|
||||
mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateDocumentTemplateLists();
|
||||
await invalidateTemplateLists();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -35,10 +43,7 @@ export const updateDocumentTemplateMutation = mutationOptions({
|
||||
updateDocumentTemplate(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateDocumentTemplateLists(),
|
||||
invalidateDocumentTemplateDetail(variables.id)
|
||||
]);
|
||||
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.id)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -47,9 +52,10 @@ export const deleteDocumentTemplateMutation = mutationOptions({
|
||||
mutationFn: (id: string) => deleteDocumentTemplate(id),
|
||||
onSettled: async (_data, error, id) => {
|
||||
if (!error) {
|
||||
getQueryClient().removeQueries({ queryKey: documentTemplateKeys.detail(id) });
|
||||
getQueryClient().removeQueries({ queryKey: documentTemplateKeys.versions(id) });
|
||||
await invalidateDocumentTemplateLists();
|
||||
const queryClient = getQueryClient();
|
||||
await invalidateTemplateLists();
|
||||
queryClient.removeQueries({ queryKey: documentTemplateKeys.detail(id) });
|
||||
queryClient.removeQueries({ queryKey: documentTemplateKeys.versions(id) });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -59,11 +65,99 @@ export const createDocumentTemplateVersionMutation = mutationOptions({
|
||||
createDocumentTemplateVersion(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateDocumentTemplateLists(),
|
||||
invalidateDocumentTemplateDetail(variables.id),
|
||||
invalidateDocumentTemplateVersions(variables.id)
|
||||
]);
|
||||
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.id)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const updateDocumentTemplateVersionMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
versionId,
|
||||
values
|
||||
}: {
|
||||
templateId: string;
|
||||
versionId: string;
|
||||
values: Partial<DocumentTemplateVersionMutationPayload>;
|
||||
}) => {
|
||||
void templateId;
|
||||
return updateDocumentTemplateVersion(versionId, values);
|
||||
},
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const setDocumentTemplateVersionActiveMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
versionId,
|
||||
isActive
|
||||
}: {
|
||||
templateId: string;
|
||||
versionId: string;
|
||||
isActive: boolean;
|
||||
}) => {
|
||||
void templateId;
|
||||
return setDocumentTemplateVersionActive(versionId, isActive);
|
||||
},
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createDocumentTemplateMappingMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
versionId,
|
||||
values
|
||||
}: {
|
||||
templateId: string;
|
||||
versionId: string;
|
||||
values: DocumentTemplateMappingMutationPayload;
|
||||
}) => {
|
||||
void templateId;
|
||||
return createDocumentTemplateMapping(versionId, values);
|
||||
},
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const updateDocumentTemplateMappingMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
templateId,
|
||||
mappingId,
|
||||
values
|
||||
}: {
|
||||
templateId: string;
|
||||
mappingId: string;
|
||||
values: Partial<DocumentTemplateMappingMutationPayload>;
|
||||
}) => {
|
||||
void templateId;
|
||||
return updateDocumentTemplateMapping(mappingId, values);
|
||||
},
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteDocumentTemplateMappingMutation = mutationOptions({
|
||||
mutationFn: ({ templateId, mappingId }: { templateId: string; mappingId: string }) => {
|
||||
void templateId;
|
||||
return deleteDocumentTemplateMapping(mappingId);
|
||||
},
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DocumentTemplateDetail,
|
||||
DocumentTemplateFilters,
|
||||
DocumentTemplateListItem,
|
||||
DocumentTemplateMappingMutationPayload,
|
||||
DocumentTemplateMappingRecord,
|
||||
DocumentTemplateMappingWithColumns,
|
||||
DocumentTemplateMutationPayload,
|
||||
@@ -119,21 +120,44 @@ async function assertTemplate(id: string, organizationId: string) {
|
||||
return template;
|
||||
}
|
||||
|
||||
async function listVersionsByTemplateIds(templateIds: string[], organizationId: string) {
|
||||
if (!templateIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return db
|
||||
async function assertTemplateVersion(id: string, organizationId: string) {
|
||||
const [version] = await db
|
||||
.select()
|
||||
.from(crmDocumentTemplateVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(crmDocumentTemplateVersions.id, id),
|
||||
eq(crmDocumentTemplateVersions.organizationId, organizationId),
|
||||
isNull(crmDocumentTemplateVersions.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmDocumentTemplateVersions.createdAt));
|
||||
.limit(1);
|
||||
|
||||
if (!version) {
|
||||
throw new AuthError('Document template version not found', 404);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
async function assertTemplateMapping(id: string, organizationId: string) {
|
||||
const [mapping] = await db
|
||||
.select()
|
||||
.from(crmDocumentTemplateMappings)
|
||||
.where(
|
||||
and(
|
||||
eq(crmDocumentTemplateMappings.id, id),
|
||||
eq(crmDocumentTemplateMappings.organizationId, organizationId),
|
||||
isNull(crmDocumentTemplateMappings.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!mapping) {
|
||||
throw new AuthError('Document template mapping not found', 404);
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export async function resolveTemplateMappings(
|
||||
@@ -417,6 +441,169 @@ export async function createDocumentTemplateVersion(
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function updateDocumentTemplateVersion(
|
||||
versionId: string,
|
||||
organizationId: string,
|
||||
payload: Partial<DocumentTemplateVersionMutationPayload>
|
||||
) {
|
||||
const current = await assertTemplateVersion(versionId, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmDocumentTemplateVersions)
|
||||
.set({
|
||||
version: payload.version?.trim() ?? current.version,
|
||||
filePath: payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null,
|
||||
schemaJson: payload.schemaJson ?? current.schemaJson,
|
||||
previewImageUrl:
|
||||
payload.previewImageUrl === undefined
|
||||
? current.previewImageUrl
|
||||
: payload.previewImageUrl?.trim() || null,
|
||||
isActive: payload.isActive ?? current.isActive,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmDocumentTemplateVersions.id, versionId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function setDocumentTemplateVersionActive(
|
||||
versionId: string,
|
||||
organizationId: string,
|
||||
isActive: boolean
|
||||
) {
|
||||
const version = await assertTemplateVersion(versionId, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmDocumentTemplateVersions)
|
||||
.set({
|
||||
isActive,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmDocumentTemplateVersions.id, versionId))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
before: version,
|
||||
after: updated
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDocumentTemplateMapping(
|
||||
versionId: string,
|
||||
organizationId: string,
|
||||
payload: DocumentTemplateMappingMutationPayload
|
||||
) {
|
||||
await assertTemplateVersion(versionId, organizationId);
|
||||
const [created] = await db
|
||||
.insert(crmDocumentTemplateMappings)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
templateVersionId: versionId,
|
||||
placeholderKey: payload.placeholderKey.trim(),
|
||||
sourcePath: payload.sourcePath.trim(),
|
||||
dataType: payload.dataType,
|
||||
sheetName: payload.sheetName?.trim() || null,
|
||||
defaultValue: payload.defaultValue?.trim() || null,
|
||||
formatMask: payload.formatMask?.trim() || null,
|
||||
sortOrder: payload.sortOrder ?? 0
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (payload.columns?.length) {
|
||||
await db.insert(crmDocumentTemplateTableColumns).values(
|
||||
payload.columns.map((column) => ({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
mappingId: created.id,
|
||||
columnName: column.columnName.trim(),
|
||||
sourceField: column.sourceField.trim(),
|
||||
columnLetter: column.columnLetter?.trim() || null,
|
||||
sortOrder: column.sortOrder ?? 0,
|
||||
formatMask: column.formatMask?.trim() || null
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const [mapping] = await resolveTemplateMappings(versionId, organizationId).then((items) =>
|
||||
items.filter((item) => item.id === created.id)
|
||||
);
|
||||
|
||||
return mapping ?? { ...mapMappingRecord(created), columns: [] };
|
||||
}
|
||||
|
||||
export async function updateDocumentTemplateMapping(
|
||||
mappingId: string,
|
||||
organizationId: string,
|
||||
payload: Partial<DocumentTemplateMappingMutationPayload>
|
||||
) {
|
||||
const current = await assertTemplateMapping(mappingId, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmDocumentTemplateMappings)
|
||||
.set({
|
||||
placeholderKey: payload.placeholderKey?.trim() ?? current.placeholderKey,
|
||||
sourcePath: payload.sourcePath?.trim() ?? current.sourcePath,
|
||||
dataType: payload.dataType ?? current.dataType,
|
||||
sheetName: payload.sheetName === undefined ? current.sheetName : payload.sheetName?.trim() || null,
|
||||
defaultValue:
|
||||
payload.defaultValue === undefined ? current.defaultValue : payload.defaultValue?.trim() || null,
|
||||
formatMask:
|
||||
payload.formatMask === undefined ? current.formatMask : payload.formatMask?.trim() || null,
|
||||
sortOrder: payload.sortOrder ?? current.sortOrder,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmDocumentTemplateMappings.id, mappingId))
|
||||
.returning();
|
||||
|
||||
if (payload.columns) {
|
||||
await db
|
||||
.delete(crmDocumentTemplateTableColumns)
|
||||
.where(eq(crmDocumentTemplateTableColumns.mappingId, mappingId));
|
||||
|
||||
if (payload.columns.length) {
|
||||
await db.insert(crmDocumentTemplateTableColumns).values(
|
||||
payload.columns.map((column) => ({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
mappingId,
|
||||
columnName: column.columnName.trim(),
|
||||
sourceField: column.sourceField.trim(),
|
||||
columnLetter: column.columnLetter?.trim() || null,
|
||||
sortOrder: column.sortOrder ?? 0,
|
||||
formatMask: column.formatMask?.trim() || null
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [mapping] = await resolveTemplateMappings(updated.templateVersionId, organizationId).then((items) =>
|
||||
items.filter((item) => item.id === mappingId)
|
||||
);
|
||||
|
||||
return mapping ?? { ...mapMappingRecord(updated), columns: [] };
|
||||
}
|
||||
|
||||
export async function deleteDocumentTemplateMapping(mappingId: string, organizationId: string) {
|
||||
const current = await assertTemplateMapping(mappingId, organizationId);
|
||||
|
||||
await db
|
||||
.delete(crmDocumentTemplateTableColumns)
|
||||
.where(eq(crmDocumentTemplateTableColumns.mappingId, mappingId));
|
||||
|
||||
const [deleted] = await db
|
||||
.update(crmDocumentTemplateMappings)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmDocumentTemplateMappings.id, mappingId))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
before: current,
|
||||
after: deleted
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveTemplateForDocument(
|
||||
organizationId: string,
|
||||
params: ResolveTemplateParams
|
||||
|
||||
@@ -3,12 +3,15 @@ import type {
|
||||
DocumentTemplateDetailResponse,
|
||||
DocumentTemplateFilters,
|
||||
DocumentTemplateListResponse,
|
||||
DocumentTemplateMappingMutationPayload,
|
||||
DocumentTemplateMutationPayload,
|
||||
DocumentTemplateVersionMutationPayload,
|
||||
DocumentTemplateVersionsResponse,
|
||||
MutationSuccessResponse
|
||||
} from './types';
|
||||
|
||||
const BASE_PATH = '/crm/settings/document-templates';
|
||||
|
||||
export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
@@ -17,17 +20,15 @@ export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}
|
||||
if (filters.isActive) searchParams.set('isActive', filters.isActive);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return apiClient<DocumentTemplateListResponse>(
|
||||
`/crm/document-templates${query ? `?${query}` : ''}`
|
||||
);
|
||||
return apiClient<DocumentTemplateListResponse>(`${BASE_PATH}${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getDocumentTemplateById(id: string) {
|
||||
return apiClient<DocumentTemplateDetailResponse>(`/crm/document-templates/${id}`);
|
||||
return apiClient<DocumentTemplateDetailResponse>(`${BASE_PATH}/${id}`);
|
||||
}
|
||||
|
||||
export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>('/crm/document-templates', {
|
||||
return apiClient<MutationSuccessResponse>(BASE_PATH, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
@@ -37,28 +38,71 @@ export async function updateDocumentTemplate(
|
||||
id: string,
|
||||
data: Partial<DocumentTemplateMutationPayload>
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}`, {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDocumentTemplate(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}`, {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDocumentTemplateVersions(id: string) {
|
||||
return apiClient<DocumentTemplateVersionsResponse>(`/crm/document-templates/${id}/versions`);
|
||||
return apiClient<DocumentTemplateVersionsResponse>(`${BASE_PATH}/${id}/versions`);
|
||||
}
|
||||
|
||||
export async function createDocumentTemplateVersion(
|
||||
id: string,
|
||||
data: DocumentTemplateVersionMutationPayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}/versions`, {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}/versions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDocumentTemplateVersion(
|
||||
id: string,
|
||||
data: Partial<DocumentTemplateVersionMutationPayload>
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function setDocumentTemplateVersionActive(id: string, isActive: boolean) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive })
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDocumentTemplateMapping(
|
||||
versionId: string,
|
||||
data: DocumentTemplateMappingMutationPayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${versionId}/mappings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDocumentTemplateMapping(
|
||||
id: string,
|
||||
data: Partial<DocumentTemplateMappingMutationPayload>
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDocumentTemplateMapping(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,6 +60,26 @@ export interface DocumentTemplateTableColumnRecord {
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateMappingColumnMutationPayload {
|
||||
id?: string;
|
||||
columnName: string;
|
||||
sourceField: string;
|
||||
columnLetter?: string | null;
|
||||
sortOrder?: number;
|
||||
formatMask?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentTemplateMappingMutationPayload {
|
||||
placeholderKey: string;
|
||||
sourcePath: string;
|
||||
dataType: DocumentTemplateMappingRecord['dataType'];
|
||||
sheetName?: string | null;
|
||||
defaultValue?: string | null;
|
||||
formatMask?: string | null;
|
||||
sortOrder?: number;
|
||||
columns?: DocumentTemplateMappingColumnMutationPayload[];
|
||||
}
|
||||
|
||||
export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord {
|
||||
columns: DocumentTemplateTableColumnRecord[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user