task-p.6 migrate

This commit is contained in:
phaichayon
2026-06-30 11:50:50 +07:00
parent b0081c7031
commit 9ab254ef0d
27 changed files with 3170 additions and 32 deletions

View File

@@ -0,0 +1,764 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { formatDateTime } from '@/lib/date-format';
import {
activateDocumentLibraryVersionMutation,
archiveDocumentLibraryVersionMutation,
createDocumentLibraryMutation,
deleteDraftDocumentLibraryVersionMutation,
publishDocumentLibraryVersionMutation,
updateDocumentLibraryMutation,
uploadDocumentLibraryVersionMutation
} from '../mutations';
import {
documentLibrariesQueryOptions,
documentLibraryByIdOptions
} from '../queries';
import {
getActiveDocumentLibraryDownloadUrl,
getActiveDocumentLibraryPreviewUrl,
getDocumentLibraryDownloadUrl,
getDocumentLibraryPreviewUrl
} from '../service';
import {
DOCUMENT_LIBRARY_BRANDS,
DOCUMENT_LIBRARY_LANGUAGES,
DOCUMENT_LIBRARY_PRODUCT_TYPES,
DOCUMENT_LIBRARY_TYPES,
type DocumentLibraryDetail,
type DocumentLibraryListItem,
type DocumentLibraryMutationPayload,
type DocumentLibraryVersionRecord
} from '../types';
type LibraryFormState = {
code: string;
name: string;
documentType: DocumentLibraryMutationPayload['documentType'];
description: string;
brand: DocumentLibraryMutationPayload['brand'];
language: DocumentLibraryMutationPayload['language'];
productType: DocumentLibraryMutationPayload['productType'];
isActive: boolean;
};
type VersionUploadState = {
version: string;
file: File | null;
};
function toLibraryState(library?: Partial<DocumentLibraryListItem>): LibraryFormState {
return {
code: library?.code ?? '',
name: library?.name ?? '',
documentType: (library?.documentType as LibraryFormState['documentType']) ?? 'sla',
description: library?.description ?? '',
brand: (library?.brand as LibraryFormState['brand']) ?? 'alla',
language: (library?.language as LibraryFormState['language']) ?? 'th',
productType: (library?.productType as LibraryFormState['productType']) ?? 'all',
isActive: library?.isActive ?? true
};
}
function buildLibraryPayload(state: LibraryFormState): DocumentLibraryMutationPayload {
return {
code: state.code,
name: state.name,
documentType: state.documentType,
description: state.description || null,
brand: state.brand,
language: state.language,
productType: state.productType,
isActive: state.isActive
};
}
function openFile(url: string) {
window.open(url, '_blank', 'noopener,noreferrer');
}
function LibraryDialog({
open,
onOpenChange,
library
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
library?: DocumentLibraryListItem;
}) {
const [state, setState] = React.useState<LibraryFormState>(toLibraryState(library));
const createMutation = useMutation({
...createDocumentLibraryMutation,
onSuccess: () => {
toast.success('Document library item created');
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Create failed');
}
});
const updateMutation = useMutation({
...updateDocumentLibraryMutation,
onSuccess: () => {
toast.success('Document library item updated');
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Update failed');
}
});
React.useEffect(() => {
if (open) {
setState(toLibraryState(library));
}
}, [open, library]);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload = buildLibraryPayload(state);
if (library) {
await updateMutation.mutateAsync({ id: library.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>{library ? 'Edit Library Item' : 'Create Library Item'}</DialogTitle>
<DialogDescription>
Manage reusable static PDF documents before later assembly flows.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<div className='grid gap-4 md:grid-cols-2'>
<Input
placeholder='Code'
value={state.code}
onChange={(event) =>
setState((current) => ({ ...current, code: event.target.value }))
}
/>
<Input
placeholder='Name'
value={state.name}
onChange={(event) =>
setState((current) => ({ ...current, name: event.target.value }))
}
/>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<Select
value={state.documentType}
onValueChange={(value: LibraryFormState['documentType']) =>
setState((current) => ({ ...current, documentType: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='Document type' />
</SelectTrigger>
<SelectContent>
{DOCUMENT_LIBRARY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={state.brand}
onValueChange={(value: LibraryFormState['brand']) =>
setState((current) => ({ ...current, brand: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='Brand' />
</SelectTrigger>
<SelectContent>
{DOCUMENT_LIBRARY_BRANDS.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<Select
value={state.language}
onValueChange={(value: LibraryFormState['language']) =>
setState((current) => ({ ...current, language: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='Language' />
</SelectTrigger>
<SelectContent>
{DOCUMENT_LIBRARY_LANGUAGES.map((language) => (
<SelectItem key={language} value={language}>
{language}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={state.productType}
onValueChange={(value: LibraryFormState['productType']) =>
setState((current) => ({ ...current, productType: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='Product type' />
</SelectTrigger>
<SelectContent>
{DOCUMENT_LIBRARY_PRODUCT_TYPES.map((productType) => (
<SelectItem key={productType} value={productType}>
{productType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Textarea
placeholder='Description'
value={state.description}
onChange={(event) =>
setState((current) => ({ ...current, description: event.target.value }))
}
/>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active Library</div>
<div className='text-muted-foreground text-sm'>
Inactive libraries remain visible for audit and historical 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}>
{library ? 'Save Changes' : 'Create Library'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function VersionUploadDialog({
open,
onOpenChange,
libraryId
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
libraryId: string;
}) {
const [state, setState] = React.useState<VersionUploadState>({ version: '', file: null });
const uploadMutation = useMutation({
...uploadDocumentLibraryVersionMutation,
onSuccess: () => {
toast.success('Document version uploaded');
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Upload failed');
}
});
React.useEffect(() => {
if (open) {
setState({ version: '', file: null });
}
}, [open]);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
if (!state.file) {
toast.error('Please select a PDF file');
return;
}
await uploadMutation.mutateAsync({
libraryId,
values: {
version: state.version,
file: state.file
}
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Version</DialogTitle>
<DialogDescription>
Draft versions accept PDF uploads only. Publish and activate are separate actions.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<Input
placeholder='Version'
value={state.version}
onChange={(event) =>
setState((current) => ({ ...current, version: event.target.value }))
}
/>
<Input
type='file'
accept='application/pdf,.pdf'
onChange={(event) =>
setState((current) => ({
...current,
file: event.target.files?.[0] ?? null
}))
}
/>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={uploadMutation.isPending}>
Upload Version
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function VersionActions({
libraryId,
version
}: {
libraryId: string;
version: DocumentLibraryVersionRecord;
}) {
const publishMutation = useMutation({
...publishDocumentLibraryVersionMutation,
onSuccess: () => toast.success('Version published'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Publish failed')
});
const activateMutation = useMutation({
...activateDocumentLibraryVersionMutation,
onSuccess: () => toast.success('Version activated'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Activate failed')
});
const archiveMutation = useMutation({
...archiveDocumentLibraryVersionMutation,
onSuccess: () => toast.success('Version archived'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Archive failed')
});
const deleteMutation = useMutation({
...deleteDraftDocumentLibraryVersionMutation,
onSuccess: () => toast.success('Draft version deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
});
return (
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => openFile(getDocumentLibraryPreviewUrl(version.id))}
>
Preview
</Button>
<Button
variant='outline'
size='sm'
onClick={() => openFile(getDocumentLibraryDownloadUrl(version.id))}
>
Download
</Button>
{version.status === 'draft' ? (
<Button
size='sm'
variant='outline'
isLoading={publishMutation.isPending}
onClick={() => publishMutation.mutate({ libraryId, versionId: version.id })}
>
Publish
</Button>
) : null}
{version.status === 'published' ? (
<Button
size='sm'
isLoading={activateMutation.isPending}
onClick={() => activateMutation.mutate({ libraryId, versionId: version.id })}
>
Activate
</Button>
) : null}
{(version.status === 'draft' || version.status === 'published') && !version.isActive ? (
<Button
size='sm'
variant='outline'
isLoading={archiveMutation.isPending}
onClick={() => archiveMutation.mutate({ libraryId, versionId: version.id })}
>
Archive
</Button>
) : null}
{version.status === 'draft' ? (
<Button
size='sm'
variant='outline'
isLoading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate({ libraryId, versionId: version.id })}
>
Delete Draft
</Button>
) : null}
</div>
);
}
function DocumentLibraryDetailSheet({
libraryId,
open,
onOpenChange
}: {
libraryId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { data } = useSuspenseQuery(documentLibraryByIdOptions(libraryId));
const [uploadDialogOpen, setUploadDialogOpen] = React.useState(false);
const library = data.library;
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side='right' className='w-full overflow-y-auto sm:max-w-5xl'>
<SheetHeader>
<SheetTitle>{library.name}</SheetTitle>
<SheetDescription>
{library.code} {library.documentType} {library.brand} {library.language}
</SheetDescription>
</SheetHeader>
<div className='space-y-6 pb-6'>
<div className='grid gap-4 md:grid-cols-4'>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Product Type</div>
<div className='mt-1 text-sm font-medium'>{library.productType}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Versions</div>
<div className='mt-1 text-sm font-medium'>{library.versions.length}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Updated At</div>
<div className='mt-1 text-sm font-medium'>{formatDateTime(library.updatedAt)}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Description</div>
<div className='mt-1 text-sm font-medium'>
{library.description || 'No description provided'}
</div>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button onClick={() => setUploadDialogOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' />
Upload Version
</Button>
<Button
variant='outline'
onClick={() => openFile(getActiveDocumentLibraryPreviewUrl(library.id))}
>
Preview Active
</Button>
<Button
variant='outline'
onClick={() => openFile(getActiveDocumentLibraryDownloadUrl(library.id))}
>
Download Active
</Button>
</div>
{library.versions.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No document versions uploaded yet.
</div>
) : (
<div className='space-y-4'>
{library.versions.map((version) => (
<div key={version.id} className='rounded-xl border p-4'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<div className='text-lg font-semibold'>{version.version}</div>
<Badge variant={version.isActive ? 'secondary' : 'outline'}>
{version.status}
</Badge>
</div>
<div className='text-muted-foreground text-sm'>
{version.fileName} {version.pageCount} page(s) {version.fileSize} bytes
</div>
<div className='text-muted-foreground text-xs'>
Created {formatDateTime(version.createdAt)}
{version.publishedAt
? ` • Published ${formatDateTime(version.publishedAt)}`
: ''}
{version.archivedAt
? ` • Archived ${formatDateTime(version.archivedAt)}`
: ''}
</div>
</div>
<VersionActions libraryId={library.id} version={version} />
</div>
</div>
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
<VersionUploadDialog
open={uploadDialogOpen}
onOpenChange={setUploadDialogOpen}
libraryId={library.id}
/>
</>
);
}
export function DocumentLibrarySettings() {
const { data } = useSuspenseQuery(documentLibrariesQueryOptions());
const [dialogState, setDialogState] = React.useState<{
open: boolean;
library?: DocumentLibraryListItem;
}>({ open: false });
const [detailState, setDetailState] = React.useState<{
open: boolean;
libraryId?: string;
}>({ open: false });
const [filters, setFilters] = React.useState({
documentType: '__all__',
brand: '__all__',
language: '__all__',
productType: '__all__'
});
const filteredItems = React.useMemo(() => {
return data.items.filter((item) => {
if (filters.documentType !== '__all__' && item.documentType !== filters.documentType) {
return false;
}
if (filters.brand !== '__all__' && item.brand !== filters.brand) {
return false;
}
if (filters.language !== '__all__' && item.language !== filters.language) {
return false;
}
if (filters.productType !== '__all__' && item.productType !== filters.productType) {
return false;
}
return true;
});
}, [data.items, filters]);
return (
<div className='space-y-6'>
<div className='flex flex-col gap-4 rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-4'>
<div>
<div className='font-medium'>Document Library Foundation</div>
<div className='text-muted-foreground text-sm'>
Manage reusable PDF documents such as SLA, warranty, datasheet, and profile files.
</div>
</div>
<Button onClick={() => setDialogState({ open: true })}>
<Icons.add className='mr-2 h-4 w-4' />
Create Library Item
</Button>
</div>
<div className='grid gap-4 md:grid-cols-4'>
<Select
value={filters.documentType}
onValueChange={(value) =>
setFilters((current) => ({ ...current, documentType: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='All types' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All types</SelectItem>
{DOCUMENT_LIBRARY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.brand}
onValueChange={(value) => setFilters((current) => ({ ...current, brand: value }))}
>
<SelectTrigger>
<SelectValue placeholder='All brands' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All brands</SelectItem>
{DOCUMENT_LIBRARY_BRANDS.map((brand) => (
<SelectItem key={brand} value={brand}>
{brand}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.language}
onValueChange={(value) =>
setFilters((current) => ({ ...current, language: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='All languages' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All languages</SelectItem>
{DOCUMENT_LIBRARY_LANGUAGES.map((language) => (
<SelectItem key={language} value={language}>
{language}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.productType}
onValueChange={(value) =>
setFilters((current) => ({ ...current, productType: value }))
}
>
<SelectTrigger>
<SelectValue placeholder='All products' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All products</SelectItem>
{DOCUMENT_LIBRARY_PRODUCT_TYPES.map((productType) => (
<SelectItem key={productType} value={productType}>
{productType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{filteredItems.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No document library items match the current filters.
</div>
) : (
filteredItems.map((item) => (
<div key={item.id} className='rounded-xl border p-5'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div className='space-y-1'>
<div className='text-xl font-semibold'>{item.name}</div>
<div className='text-muted-foreground text-sm'>
{item.code} {item.documentType} {item.brand} {item.language} {' '}
{item.productType}
</div>
<div className='text-muted-foreground text-sm'>
{item.description || 'No description provided.'}
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Badge variant={item.isActive ? 'secondary' : 'outline'}>{item.status}</Badge>
<Badge variant='outline'>Active {item.activeVersion ?? '-'}</Badge>
<Badge variant='outline'>{item.versionCount} version(s)</Badge>
</div>
</div>
<div className='mt-4 flex flex-wrap gap-2'>
<Button
variant='outline'
onClick={() => setDialogState({ open: true, library: item })}
>
Edit Metadata
</Button>
<Button
onClick={() => setDetailState({ open: true, libraryId: item.id })}
>
Manage Versions
</Button>
</div>
</div>
))
)}
<LibraryDialog
open={dialogState.open}
onOpenChange={(open) => setDialogState((current) => ({ ...current, open }))}
library={dialogState.library}
/>
{detailState.open && detailState.libraryId ? (
<React.Suspense fallback={null}>
<DocumentLibraryDetailSheet
libraryId={detailState.libraryId}
open={detailState.open}
onOpenChange={(open) => setDetailState((current) => ({ ...current, open }))}
/>
</React.Suspense>
) : null}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
activateDocumentLibraryVersion,
archiveDocumentLibraryVersion,
createDocumentLibrary,
deleteDraftDocumentLibraryVersion,
publishDocumentLibraryVersion,
updateDocumentLibrary,
uploadDocumentLibraryVersion
} from './service';
import { documentLibraryKeys } from './queries';
import type {
DocumentLibraryMutationPayload,
DocumentLibraryVersionUploadPayload
} from './types';
async function invalidateLibraryLists() {
await getQueryClient().invalidateQueries({ queryKey: documentLibraryKeys.lists() });
}
async function invalidateLibraryDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: documentLibraryKeys.detail(id) });
}
export const createDocumentLibraryMutation = mutationOptions({
mutationFn: (values: DocumentLibraryMutationPayload) => createDocumentLibrary(values),
onSettled: async (_data, error) => {
if (!error) {
await invalidateLibraryLists();
}
}
});
export const updateDocumentLibraryMutation = mutationOptions({
mutationFn: ({
id,
values
}: {
id: string;
values: Partial<DocumentLibraryMutationPayload>;
}) => updateDocumentLibrary(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([invalidateLibraryLists(), invalidateLibraryDetail(variables.id)]);
}
}
});
export const uploadDocumentLibraryVersionMutation = mutationOptions({
mutationFn: ({
libraryId,
values
}: {
libraryId: string;
values: DocumentLibraryVersionUploadPayload;
}) => uploadDocumentLibraryVersion(libraryId, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateLibraryLists(),
invalidateLibraryDetail(variables.libraryId)
]);
}
}
});
function makeVersionLifecycleMutation(
action: (versionId: string) => Promise<unknown>
) {
return mutationOptions({
mutationFn: ({
libraryId,
versionId
}: {
libraryId: string;
versionId: string;
}) => action(versionId),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateLibraryLists(),
invalidateLibraryDetail(variables.libraryId)
]);
}
}
});
}
export const publishDocumentLibraryVersionMutation = makeVersionLifecycleMutation(
publishDocumentLibraryVersion
);
export const activateDocumentLibraryVersionMutation = makeVersionLifecycleMutation(
activateDocumentLibraryVersion
);
export const archiveDocumentLibraryVersionMutation = makeVersionLifecycleMutation(
archiveDocumentLibraryVersion
);
export const deleteDraftDocumentLibraryVersionMutation = makeVersionLifecycleMutation(
deleteDraftDocumentLibraryVersion
);

View File

@@ -0,0 +1,25 @@
import { queryOptions } from '@tanstack/react-query';
import { getDocumentLibraries, getDocumentLibraryById } from './service';
import type { DocumentLibraryFilters } from './types';
export const documentLibraryKeys = {
all: ['crm-document-library'] as const,
lists: () => [...documentLibraryKeys.all, 'list'] as const,
list: (filters: DocumentLibraryFilters) => [...documentLibraryKeys.lists(), filters] as const,
details: () => [...documentLibraryKeys.all, 'detail'] as const,
detail: (id: string) => [...documentLibraryKeys.details(), id] as const
};
export const documentLibrariesQueryOptions = (
filters: DocumentLibraryFilters = {}
) =>
queryOptions({
queryKey: documentLibraryKeys.list(filters),
queryFn: () => getDocumentLibraries(filters)
});
export const documentLibraryByIdOptions = (id: string) =>
queryOptions({
queryKey: documentLibraryKeys.detail(id),
queryFn: () => getDocumentLibraryById(id)
});

View File

@@ -0,0 +1,726 @@
import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm';
import {
crmDocumentLibraries,
crmDocumentLibraryVersions,
users
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
buildOrganizationStorageKey,
getStorageProvider
} from '@/features/foundation/storage/service';
import type {
DocumentLibraryDetail,
DocumentLibraryFilters,
DocumentLibraryListItem,
DocumentLibraryMutationPayload,
DocumentLibraryRecord,
DocumentLibraryVersionRecord,
DocumentLibraryVersionStatus
} from '../types';
import {
assertCanDeleteDraftVersion,
assertLifecycleTransition,
assertSupportedBrand,
assertSupportedDocumentType,
assertSupportedLanguage,
assertSupportedProductType,
normalizeDocumentLibraryCode,
parsePdfUpload
} from './validation';
function sanitizeFileName(fileName: string) {
return fileName.replace(/[^a-zA-Z0-9._-]+/g, '-');
}
function mapLibrary(row: typeof crmDocumentLibraries.$inferSelect): DocumentLibraryRecord {
return {
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name,
documentType: row.documentType as DocumentLibraryRecord['documentType'],
description: row.description,
brand: row.brand as DocumentLibraryRecord['brand'],
language: row.language as DocumentLibraryRecord['language'],
productType: row.productType as DocumentLibraryRecord['productType'],
status: row.status as DocumentLibraryRecord['status'],
isActive: row.isActive,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
function mapVersion(
row: typeof crmDocumentLibraryVersions.$inferSelect
): DocumentLibraryVersionRecord {
return {
id: row.id,
organizationId: row.organizationId,
libraryId: row.libraryId,
version: row.version,
fileName: row.fileName,
storageProvider: row.storageProvider,
storageKey: row.storageKey,
mimeType: row.mimeType,
fileSize: row.fileSize,
checksum: row.checksum,
pageCount: row.pageCount,
status: row.status as DocumentLibraryVersionStatus,
isActive: row.isActive,
publishedAt: row.publishedAt?.toISOString() ?? null,
publishedBy: row.publishedBy,
archivedAt: row.archivedAt?.toISOString() ?? null,
archivedBy: row.archivedBy,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
async function getLibraryRow(id: string, organizationId: string) {
const [library] = await db
.select()
.from(crmDocumentLibraries)
.where(
and(
eq(crmDocumentLibraries.id, id),
eq(crmDocumentLibraries.organizationId, organizationId),
isNull(crmDocumentLibraries.deletedAt)
)
)
.limit(1);
if (!library) {
throw new AuthError('Document library item not found', 404);
}
return library;
}
async function getVersionRow(versionId: string, organizationId: string) {
const [version] = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.id, versionId),
eq(crmDocumentLibraryVersions.organizationId, organizationId),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.limit(1);
if (!version) {
throw new AuthError('Document library version not found', 404);
}
return version;
}
async function ensureUniqueCode(
organizationId: string,
code: string,
excludeId?: string
) {
const rows = await db
.select({ id: crmDocumentLibraries.id })
.from(crmDocumentLibraries)
.where(
and(
eq(crmDocumentLibraries.organizationId, organizationId),
eq(crmDocumentLibraries.code, code),
isNull(crmDocumentLibraries.deletedAt)
)
);
if (rows.some((row) => row.id !== excludeId)) {
throw new Error(`Document library code already exists: ${code}`);
}
}
export async function listDocumentLibraries(
organizationId: string,
filters: DocumentLibraryFilters = {}
): Promise<DocumentLibraryListItem[]> {
const libraries = await db
.select()
.from(crmDocumentLibraries)
.where(
and(
eq(crmDocumentLibraries.organizationId, organizationId),
isNull(crmDocumentLibraries.deletedAt),
...(filters.documentType
? [eq(crmDocumentLibraries.documentType, filters.documentType)]
: []),
...(filters.brand ? [eq(crmDocumentLibraries.brand, filters.brand)] : []),
...(filters.language ? [eq(crmDocumentLibraries.language, filters.language)] : []),
...(filters.productType
? [eq(crmDocumentLibraries.productType, filters.productType)]
: []),
...(filters.isActive === 'true' ? [eq(crmDocumentLibraries.isActive, true)] : []),
...(filters.isActive === 'false' ? [eq(crmDocumentLibraries.isActive, false)] : [])
)
)
.orderBy(asc(crmDocumentLibraries.name));
if (!libraries.length) {
return [];
}
const versions = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
inArray(
crmDocumentLibraryVersions.libraryId,
libraries.map((library) => library.id)
),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.orderBy(desc(crmDocumentLibraryVersions.createdAt));
return libraries.map((library) => {
const libraryVersions = versions.filter((version) => version.libraryId === library.id);
const activeVersion =
libraryVersions.find((version) => version.isActive)?.version ?? null;
return {
...mapLibrary(library),
activeVersion,
versionCount: libraryVersions.length
};
});
}
export async function getDocumentLibraryById(
id: string,
organizationId: string
): Promise<DocumentLibraryDetail> {
const library = await getLibraryRow(id, organizationId);
const versions = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, id),
eq(crmDocumentLibraryVersions.organizationId, organizationId),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.orderBy(desc(crmDocumentLibraryVersions.createdAt));
return {
...mapLibrary(library),
versions: versions.map(mapVersion)
};
}
export async function createDocumentLibrary(input: {
organizationId: string;
userId: string;
values: DocumentLibraryMutationPayload;
}) {
assertSupportedDocumentType(input.values.documentType);
assertSupportedBrand(input.values.brand);
assertSupportedLanguage(input.values.language);
assertSupportedProductType(input.values.productType);
const code = normalizeDocumentLibraryCode(input.values.code);
await ensureUniqueCode(input.organizationId, code);
const [created] = await db
.insert(crmDocumentLibraries)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
code,
name: input.values.name.trim(),
documentType: input.values.documentType,
description: input.values.description?.trim() || null,
brand: input.values.brand,
language: input.values.language,
productType: input.values.productType,
status: input.values.isActive === false ? 'inactive' : 'active',
isActive: input.values.isActive ?? true,
createdBy: input.userId,
updatedBy: input.userId
})
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library',
entityId: created.id,
action: 'create',
afterData: created
});
return mapLibrary(created);
}
export async function updateDocumentLibrary(input: {
id: string;
organizationId: string;
userId: string;
values: Partial<DocumentLibraryMutationPayload>;
}) {
const current = await getLibraryRow(input.id, input.organizationId);
if (input.values.documentType) {
assertSupportedDocumentType(input.values.documentType);
}
if (input.values.brand) {
assertSupportedBrand(input.values.brand);
}
if (input.values.language) {
assertSupportedLanguage(input.values.language);
}
if (input.values.productType) {
assertSupportedProductType(input.values.productType);
}
const code =
typeof input.values.code === 'string'
? normalizeDocumentLibraryCode(input.values.code)
: current.code;
await ensureUniqueCode(input.organizationId, code, current.id);
const [updated] = await db
.update(crmDocumentLibraries)
.set({
code,
name: input.values.name?.trim() ?? current.name,
documentType: input.values.documentType ?? current.documentType,
description:
input.values.description !== undefined
? input.values.description?.trim() || null
: current.description,
brand: input.values.brand ?? current.brand,
language: input.values.language ?? current.language,
productType: input.values.productType ?? current.productType,
status:
input.values.isActive !== undefined
? input.values.isActive
? 'active'
: 'inactive'
: (current.status as 'active' | 'inactive'),
isActive: input.values.isActive ?? current.isActive,
updatedBy: input.userId,
updatedAt: new Date()
})
.where(eq(crmDocumentLibraries.id, current.id))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library',
entityId: updated.id,
action: 'update',
beforeData: current,
afterData: updated
});
return mapLibrary(updated);
}
export async function uploadDocumentLibraryVersion(input: {
libraryId: string;
organizationId: string;
userId: string;
version: string;
file: {
name: string;
type: string;
size: number;
buffer: Buffer;
};
}) {
const library = await getLibraryRow(input.libraryId, input.organizationId);
const normalizedVersion = input.version.trim();
if (!normalizedVersion) {
throw new Error('Version is required.');
}
const existing = await db
.select({ id: crmDocumentLibraryVersions.id })
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, library.id),
eq(crmDocumentLibraryVersions.version, normalizedVersion),
isNull(crmDocumentLibraryVersions.deletedAt)
)
);
if (existing.length > 0) {
throw new Error(`Version already exists: ${normalizedVersion}`);
}
const parsedFile = await parsePdfUpload({
fileName: input.file.name,
mimeType: input.file.type,
size: input.file.size,
buffer: input.file.buffer
});
const versionId = crypto.randomUUID();
const storageKey = buildOrganizationStorageKey(input.organizationId, [
'document-library',
library.id,
versionId,
sanitizeFileName(parsedFile.fileName)
]);
const stored = await getStorageProvider().putObject({
key: storageKey,
body: input.file.buffer,
contentType: parsedFile.contentType,
fileName: parsedFile.fileName
});
const [created] = await db
.insert(crmDocumentLibraryVersions)
.values({
id: versionId,
organizationId: input.organizationId,
libraryId: library.id,
version: normalizedVersion,
fileName: parsedFile.fileName,
storageProvider: stored.provider,
storageKey: stored.key,
mimeType: parsedFile.contentType,
fileSize: parsedFile.fileSize,
checksum: parsedFile.checksum,
pageCount: parsedFile.pageCount,
status: 'draft',
isActive: false,
createdBy: input.userId
})
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: created.id,
action: 'upload',
afterData: created
});
return mapVersion(created);
}
async function setVersionStatus(input: {
versionId: string;
organizationId: string;
userId: string;
nextStatus: DocumentLibraryVersionStatus;
}) {
const current = await getVersionRow(input.versionId, input.organizationId);
if (input.nextStatus === 'archived' && current.isActive) {
throw new Error('Active versions must be replaced before archiving.');
}
assertLifecycleTransition(
current.status as DocumentLibraryVersionStatus,
input.nextStatus
);
const [updated] = await db
.update(crmDocumentLibraryVersions)
.set({
status: input.nextStatus,
isActive: input.nextStatus === 'active',
publishedAt:
input.nextStatus === 'published' || input.nextStatus === 'active'
? current.publishedAt ?? new Date()
: current.publishedAt,
publishedBy:
input.nextStatus === 'published' || input.nextStatus === 'active'
? current.publishedBy ?? input.userId
: current.publishedBy,
archivedAt: input.nextStatus === 'archived' ? new Date() : null,
archivedBy: input.nextStatus === 'archived' ? input.userId : null,
updatedAt: new Date()
})
.where(eq(crmDocumentLibraryVersions.id, current.id))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: updated.id,
action: input.nextStatus,
beforeData: current,
afterData: updated
});
return mapVersion(updated);
}
export async function publishDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
return setVersionStatus({ ...input, nextStatus: 'published' });
}
export async function activateDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
const current = await getVersionRow(input.versionId, input.organizationId);
assertLifecycleTransition(current.status as DocumentLibraryVersionStatus, 'active');
const [updated] = await db.transaction(async (tx) => {
await tx
.update(crmDocumentLibraryVersions)
.set({
isActive: false,
updatedAt: new Date()
})
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, current.libraryId),
eq(crmDocumentLibraryVersions.organizationId, input.organizationId),
isNull(crmDocumentLibraryVersions.deletedAt)
)
);
const [next] = await tx
.update(crmDocumentLibraryVersions)
.set({
status: 'active',
isActive: true,
publishedAt: current.publishedAt ?? new Date(),
publishedBy: current.publishedBy ?? input.userId,
updatedAt: new Date()
})
.where(eq(crmDocumentLibraryVersions.id, current.id))
.returning();
await tx
.update(crmDocumentLibraries)
.set({
isActive: true,
status: 'active',
updatedAt: new Date(),
updatedBy: input.userId
})
.where(eq(crmDocumentLibraries.id, current.libraryId));
return [next] as const;
});
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: updated.id,
action: 'activate',
beforeData: current,
afterData: updated
});
return mapVersion(updated);
}
export async function archiveDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
return setVersionStatus({ ...input, nextStatus: 'archived' });
}
export async function deleteDraftDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
const current = await getVersionRow(input.versionId, input.organizationId);
assertCanDeleteDraftVersion(
current.status as DocumentLibraryVersionStatus,
current.isActive
);
await getStorageProvider().deleteObject({ key: current.storageKey });
const [updated] = await db
.update(crmDocumentLibraryVersions)
.set({
deletedAt: new Date(),
updatedAt: new Date()
})
.where(eq(crmDocumentLibraryVersions.id, current.id))
.returning();
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: current.id,
action: 'delete',
beforeData: current,
afterData: updated
});
return mapVersion(updated);
}
async function streamVersionFile(input: {
versionId: string;
organizationId: string;
userId: string;
action: 'preview' | 'download';
}) {
const version = await getVersionRow(input.versionId, input.organizationId);
const object = await getStorageProvider().getObject({ key: version.storageKey });
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_document_library_version',
entityId: version.id,
action: input.action,
afterData: {
storageKey: version.storageKey,
fileName: version.fileName
}
});
return {
version: mapVersion(version),
object: {
...object,
fileName: version.fileName,
contentType: version.mimeType,
size: version.fileSize,
checksum: version.checksum
}
};
}
export async function previewDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
return streamVersionFile({ ...input, action: 'preview' });
}
export async function downloadDocumentLibraryVersion(input: {
versionId: string;
organizationId: string;
userId: string;
}) {
return streamVersionFile({ ...input, action: 'download' });
}
export async function downloadActiveDocumentLibraryVersion(input: {
libraryId: string;
organizationId: string;
userId: string;
}) {
const [activeVersion] = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, input.libraryId),
eq(crmDocumentLibraryVersions.organizationId, input.organizationId),
eq(crmDocumentLibraryVersions.isActive, true),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.limit(1);
if (!activeVersion) {
throw new AuthError('No active document version found', 404);
}
return downloadDocumentLibraryVersion({
versionId: activeVersion.id,
organizationId: input.organizationId,
userId: input.userId
});
}
export async function previewActiveDocumentLibraryVersion(input: {
libraryId: string;
organizationId: string;
userId: string;
}) {
const [activeVersion] = await db
.select()
.from(crmDocumentLibraryVersions)
.where(
and(
eq(crmDocumentLibraryVersions.libraryId, input.libraryId),
eq(crmDocumentLibraryVersions.organizationId, input.organizationId),
eq(crmDocumentLibraryVersions.isActive, true),
isNull(crmDocumentLibraryVersions.deletedAt)
)
)
.limit(1);
if (!activeVersion) {
throw new AuthError('No active document version found', 404);
}
return previewDocumentLibraryVersion({
versionId: activeVersion.id,
organizationId: input.organizationId,
userId: input.userId
});
}
export async function listDocumentLibraryPublishers(
versionIds: string[]
): Promise<Record<string, string | null>> {
if (!versionIds.length) {
return {};
}
const rows = await db
.select({
id: users.id,
name: users.name
})
.from(users)
.where(
inArray(
users.id,
(
await db
.select({ publishedBy: crmDocumentLibraryVersions.publishedBy })
.from(crmDocumentLibraryVersions)
.where(inArray(crmDocumentLibraryVersions.id, versionIds))
)
.map((row) => row.publishedBy)
.filter((value): value is string => typeof value === 'string')
)
);
return Object.fromEntries(rows.map((row) => [row.id, row.name]));
}

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PDFDocument } from '@pdfme/pdf-lib';
import {
assertCanDeleteDraftVersion,
assertLifecycleTransition,
parsePdfUpload
} from './validation';
test('assertLifecycleTransition rejects invalid active archive transition', () => {
assert.throws(
() => assertLifecycleTransition('active', 'archived'),
/Invalid lifecycle transition/
);
});
test('assertCanDeleteDraftVersion rejects non-draft versions', () => {
assert.throws(
() => assertCanDeleteDraftVersion('published', false),
/Only inactive draft versions can be deleted/
);
});
test('parsePdfUpload accepts readable pdf and extracts page count', async () => {
const pdf = await PDFDocument.create();
pdf.addPage([200, 200]);
const bytes = await pdf.save();
const parsed = await parsePdfUpload({
fileName: 'sample.pdf',
mimeType: 'application/pdf',
size: bytes.length,
buffer: Buffer.from(bytes)
});
assert.equal(parsed.pageCount, 1);
assert.equal(parsed.contentType, 'application/pdf');
assert.equal(parsed.fileName, 'sample.pdf');
});

View File

@@ -0,0 +1,110 @@
import { PDFDocument } from '@pdfme/pdf-lib';
import { sha256 } from '@/features/foundation/storage/server/checksum';
import {
DOCUMENT_LIBRARY_BRANDS,
DOCUMENT_LIBRARY_LANGUAGES,
DOCUMENT_LIBRARY_PRODUCT_TYPES,
DOCUMENT_LIBRARY_TYPES,
type DocumentLibraryBrand,
type DocumentLibraryLanguage,
type DocumentLibraryProductType,
type DocumentLibraryType,
type DocumentLibraryVersionStatus
} from '../types';
export const MAX_DOCUMENT_LIBRARY_FILE_SIZE = 20 * 1024 * 1024;
const ALLOWED_STATUS_TRANSITIONS: Record<
DocumentLibraryVersionStatus,
DocumentLibraryVersionStatus[]
> = {
draft: ['published', 'archived'],
published: ['active', 'archived'],
active: [],
archived: []
};
export function normalizeDocumentLibraryCode(value: string) {
return value.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
export function assertSupportedDocumentType(value: string): asserts value is DocumentLibraryType {
if (!DOCUMENT_LIBRARY_TYPES.includes(value as DocumentLibraryType)) {
throw new Error(`Unsupported document type: ${value}`);
}
}
export function assertSupportedBrand(value: string): asserts value is DocumentLibraryBrand {
if (!DOCUMENT_LIBRARY_BRANDS.includes(value as DocumentLibraryBrand)) {
throw new Error(`Unsupported brand: ${value}`);
}
}
export function assertSupportedLanguage(
value: string
): asserts value is DocumentLibraryLanguage {
if (!DOCUMENT_LIBRARY_LANGUAGES.includes(value as DocumentLibraryLanguage)) {
throw new Error(`Unsupported language: ${value}`);
}
}
export function assertSupportedProductType(
value: string
): asserts value is DocumentLibraryProductType {
if (!DOCUMENT_LIBRARY_PRODUCT_TYPES.includes(value as DocumentLibraryProductType)) {
throw new Error(`Unsupported product type: ${value}`);
}
}
export function assertLifecycleTransition(
currentStatus: DocumentLibraryVersionStatus,
nextStatus: DocumentLibraryVersionStatus
) {
if (currentStatus === nextStatus) {
return;
}
if (!ALLOWED_STATUS_TRANSITIONS[currentStatus].includes(nextStatus)) {
throw new Error(`Invalid lifecycle transition: ${currentStatus} -> ${nextStatus}`);
}
}
export function assertCanDeleteDraftVersion(status: DocumentLibraryVersionStatus, isActive: boolean) {
if (status !== 'draft' || isActive) {
throw new Error('Only inactive draft versions can be deleted.');
}
}
export async function parsePdfUpload(args: {
fileName: string;
mimeType: string;
size: number;
buffer: Buffer;
}) {
if (args.mimeType !== 'application/pdf') {
throw new Error('Only PDF uploads are supported.');
}
if (args.size <= 0) {
throw new Error('Uploaded file is empty.');
}
if (args.size > MAX_DOCUMENT_LIBRARY_FILE_SIZE) {
throw new Error('Uploaded PDF exceeds the size limit.');
}
const pdf = await PDFDocument.load(args.buffer);
const pageCount = pdf.getPageCount();
if (pageCount <= 0) {
throw new Error('Uploaded PDF must contain at least one page.');
}
return {
contentType: 'application/pdf' as const,
checksum: sha256(args.buffer),
fileSize: args.size,
fileName: args.fileName,
pageCount
};
}

View File

@@ -0,0 +1,119 @@
import { apiClient } from '@/lib/api-client';
import type {
DocumentLibraryDetailResponse,
DocumentLibraryFilters,
DocumentLibraryListResponse,
DocumentLibraryMutationPayload,
DocumentLibraryVersionActionResponse,
DocumentLibraryVersionUploadPayload,
MutationSuccessResponse
} from './types';
const BASE_PATH = '/crm/settings/document-library';
async function uploadFormData<T>(endpoint: string, formData: FormData): Promise<T> {
const response = await fetch(`/api${endpoint}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as
| { message?: string }
| null;
throw new Error(payload?.message || `API error: ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function getDocumentLibraries(filters: DocumentLibraryFilters = {}) {
const searchParams = new URLSearchParams();
if (filters.documentType) searchParams.set('documentType', filters.documentType);
if (filters.brand) searchParams.set('brand', filters.brand);
if (filters.language) searchParams.set('language', filters.language);
if (filters.productType) searchParams.set('productType', filters.productType);
if (filters.isActive) searchParams.set('isActive', filters.isActive);
const query = searchParams.toString();
return apiClient<DocumentLibraryListResponse>(`${BASE_PATH}${query ? `?${query}` : ''}`);
}
export async function getDocumentLibraryById(id: string) {
return apiClient<DocumentLibraryDetailResponse>(`${BASE_PATH}/${id}`);
}
export async function createDocumentLibrary(values: DocumentLibraryMutationPayload) {
return apiClient<MutationSuccessResponse>(BASE_PATH, {
method: 'POST',
body: JSON.stringify(values)
});
}
export async function updateDocumentLibrary(
id: string,
values: Partial<DocumentLibraryMutationPayload>
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
method: 'PATCH',
body: JSON.stringify(values)
});
}
export async function uploadDocumentLibraryVersion(
libraryId: string,
payload: DocumentLibraryVersionUploadPayload
) {
const formData = new FormData();
formData.set('version', payload.version);
formData.set('file', payload.file);
return uploadFormData<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/${libraryId}/versions`,
formData
);
}
export async function publishDocumentLibraryVersion(versionId: string) {
return apiClient<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/versions/${versionId}/publish`,
{ method: 'POST', body: JSON.stringify({}) }
);
}
export async function activateDocumentLibraryVersion(versionId: string) {
return apiClient<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/versions/${versionId}/activate`,
{ method: 'POST', body: JSON.stringify({}) }
);
}
export async function archiveDocumentLibraryVersion(versionId: string) {
return apiClient<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/versions/${versionId}/archive`,
{ method: 'POST', body: JSON.stringify({}) }
);
}
export async function deleteDraftDocumentLibraryVersion(versionId: string) {
return apiClient<DocumentLibraryVersionActionResponse>(
`${BASE_PATH}/versions/${versionId}`,
{ method: 'DELETE' }
);
}
export function getDocumentLibraryPreviewUrl(versionId: string) {
return `/api${BASE_PATH}/versions/${versionId}/preview`;
}
export function getDocumentLibraryDownloadUrl(versionId: string) {
return `/api${BASE_PATH}/versions/${versionId}/download`;
}
export function getActiveDocumentLibraryPreviewUrl(libraryId: string) {
return `/api${BASE_PATH}/${libraryId}/preview`;
}
export function getActiveDocumentLibraryDownloadUrl(libraryId: string) {
return `/api${BASE_PATH}/${libraryId}/download`;
}

View File

@@ -0,0 +1,130 @@
export const DOCUMENT_LIBRARY_TYPES = [
'sla',
'warranty',
'terms_conditions',
'company_profile',
'datasheet',
'drawing',
'certificate',
'appendix',
'safety_document',
'installation_manual'
] as const;
export const DOCUMENT_LIBRARY_BRANDS = ['alla', 'onvalla', 'generic'] as const;
export const DOCUMENT_LIBRARY_LANGUAGES = ['th', 'en'] as const;
export const DOCUMENT_LIBRARY_PRODUCT_TYPES = [
'all',
'crane',
'dock_door',
'solar',
'service'
] as const;
export const DOCUMENT_LIBRARY_VERSION_STATUSES = [
'draft',
'published',
'active',
'archived'
] as const;
export type DocumentLibraryType = (typeof DOCUMENT_LIBRARY_TYPES)[number];
export type DocumentLibraryBrand = (typeof DOCUMENT_LIBRARY_BRANDS)[number];
export type DocumentLibraryLanguage = (typeof DOCUMENT_LIBRARY_LANGUAGES)[number];
export type DocumentLibraryProductType = (typeof DOCUMENT_LIBRARY_PRODUCT_TYPES)[number];
export type DocumentLibraryVersionStatus =
(typeof DOCUMENT_LIBRARY_VERSION_STATUSES)[number];
export interface DocumentLibraryRecord {
id: string;
organizationId: string;
code: string;
name: string;
documentType: DocumentLibraryType;
description: string | null;
brand: DocumentLibraryBrand;
language: DocumentLibraryLanguage;
productType: DocumentLibraryProductType;
status: 'active' | 'inactive';
isActive: boolean;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface DocumentLibraryVersionRecord {
id: string;
organizationId: string;
libraryId: string;
version: string;
fileName: string;
storageProvider: string;
storageKey: string;
mimeType: string;
fileSize: number;
checksum: string;
pageCount: number;
status: DocumentLibraryVersionStatus;
isActive: boolean;
publishedAt: string | null;
publishedBy: string | null;
archivedAt: string | null;
archivedBy: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface DocumentLibraryListItem extends DocumentLibraryRecord {
activeVersion: string | null;
versionCount: number;
}
export interface DocumentLibraryDetail extends DocumentLibraryRecord {
versions: DocumentLibraryVersionRecord[];
}
export interface DocumentLibraryFilters {
documentType?: DocumentLibraryType;
brand?: DocumentLibraryBrand;
language?: DocumentLibraryLanguage;
productType?: DocumentLibraryProductType;
isActive?: 'true' | 'false';
}
export interface DocumentLibraryMutationPayload {
code: string;
name: string;
documentType: DocumentLibraryType;
description?: string | null;
brand: DocumentLibraryBrand;
language: DocumentLibraryLanguage;
productType: DocumentLibraryProductType;
isActive?: boolean;
}
export interface DocumentLibraryVersionUploadPayload {
version: string;
file: File;
}
export interface MutationSuccessResponse {
success: boolean;
message: string;
}
export interface DocumentLibraryListResponse extends MutationSuccessResponse {
time: string;
items: DocumentLibraryListItem[];
}
export interface DocumentLibraryDetailResponse extends MutationSuccessResponse {
time: string;
library: DocumentLibraryDetail;
}
export interface DocumentLibraryVersionActionResponse extends MutationSuccessResponse {
version: DocumentLibraryVersionRecord;
}

View File

@@ -15,6 +15,14 @@ import {
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
@@ -290,7 +298,7 @@ function TemplateDialog({
);
}
function VersionDialog({
function VersionSheet({
open,
onOpenChange,
templateId,
@@ -340,45 +348,67 @@ function VersionDialog({
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'>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side='right' className='w-full sm:max-w-3xl'>
<SheetHeader>
<SheetTitle>{version ? 'Edit Version' : 'Create Version'}</SheetTitle>
<SheetDescription>
Schema JSON is accepted as raw JSON in this admin release.
</SheetDescription>
</SheetHeader>
<form onSubmit={onSubmit} className='flex h-full flex-col gap-4 overflow-y-auto pb-6'>
<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 }))} />
<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 }))} />
<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 }))} />
<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 }))} />
<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 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 }))} />
<Switch
checked={state.isActive}
onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))}
/>
</div>
<DialogFooter>
<SheetFooter className='mt-auto sm:flex-row sm:justify-end'>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{version ? 'Save Version' : 'Create Version'}
</Button>
</DialogFooter>
</SheetFooter>
</form>
</DialogContent>
</Dialog>
</SheetContent>
</Sheet>
);
}
@@ -715,7 +745,7 @@ function TemplateDetailCard({ templateId }: { templateId: string }) {
))}
</Tabs>
<VersionDialog
<VersionSheet
open={versionDialogState.open}
onOpenChange={(open) =>
setVersionDialogState((current) => ({ ...current, open }))