summaryrefslogtreecommitdiff
path: root/lib/basic-contract/template
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
commitef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch)
tree345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib/basic-contract/template
parent9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff)
~20250428 작업사항
Diffstat (limited to 'lib/basic-contract/template')
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx359
-rw-r--r--lib/basic-contract/template/basic-contract-template-columns.tsx245
-rw-r--r--lib/basic-contract/template/basic-contract-template.tsx104
-rw-r--r--lib/basic-contract/template/basicContract-table-toolbar-actions.tsx53
-rw-r--r--lib/basic-contract/template/delete-basicContract-dialog.tsx149
-rw-r--r--lib/basic-contract/template/update-basicContract-sheet.tsx300
6 files changed, 1210 insertions, 0 deletions
diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
new file mode 100644
index 00000000..cf0986f0
--- /dev/null
+++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
@@ -0,0 +1,359 @@
+"use client";
+
+import * as React from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import { toast } from "sonner";
+import { v4 as uuidv4 } from 'uuid';
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput
+} from "@/components/ui/dropzone";
+import { Progress } from "@/components/ui/progress";
+import { useRouter } from "next/navigation"
+
+// 유효기간 필드가 추가된 계약서 템플릿 스키마 정의
+const templateFormSchema = z.object({
+ templateName: z.string().min(1, "템플릿 이름은 필수입니다."),
+ validityPeriod: z.coerce
+ .number({ invalid_type_error: "유효기간은 숫자여야 합니다." })
+ .int("유효기간은 정수여야 합니다.")
+ .min(1, "유효기간은 최소 1개월 이상이어야 합니다.")
+ .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.")
+ .default(12),
+ file: z
+ .instanceof(File, { message: "파일을 업로드해주세요." })
+ .refine((file) => file.size <= 100 * 1024 * 1024, {
+ message: "파일 크기는 100MB 이하여야 합니다.",
+ })
+ .refine(
+ (file) => file.type === 'application/pdf',
+ { message: "PDF 파일만 업로드 가능합니다." }
+ ),
+ status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"),
+});
+
+type TemplateFormValues = z.infer<typeof templateFormSchema>;
+
+export function AddTemplateDialog() {
+ const [open, setOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
+ const [uploadProgress, setUploadProgress] = React.useState(0);
+ const [showProgress, setShowProgress] = React.useState(false);
+ const router = useRouter()
+
+ // 기본값 설정
+ const defaultValues: Partial<TemplateFormValues> = {
+ templateName: "",
+ validityPeriod: 12, // 기본값 1년
+ status: "ACTIVE",
+ };
+
+ // 폼 초기화
+ const form = useForm<TemplateFormValues>({
+ resolver: zodResolver(templateFormSchema),
+ defaultValues,
+ mode: "onChange",
+ });
+
+ // 폼 값 감시
+ const templateName = form.watch("templateName");
+ const validityPeriod = form.watch("validityPeriod");
+ const file = form.watch("file");
+
+ // 파일 선택 핸들러
+ const handleFileChange = (files: File[]) => {
+ if (files.length > 0) {
+ const file = files[0];
+ setSelectedFile(file);
+ form.setValue("file", file);
+ }
+ };
+
+ // 청크 크기 설정 (1MB)
+ const CHUNK_SIZE = 1 * 1024 * 1024;
+
+ // 파일을 청크로 분할하여 업로드하는 함수
+ const uploadFileInChunks = async (file: File, fileId: string) => {
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+ setShowProgress(true);
+ setUploadProgress(0);
+
+ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
+ const start = chunkIndex * CHUNK_SIZE;
+ const end = Math.min(start + CHUNK_SIZE, file.size);
+ const chunk = file.slice(start, end);
+
+ const formData = new FormData();
+ formData.append('chunk', chunk);
+ formData.append('filename', file.name);
+ formData.append('chunkIndex', chunkIndex.toString());
+ formData.append('totalChunks', totalChunks.toString());
+ formData.append('fileId', fileId);
+
+ try {
+ const response = await fetch('/api/upload/basicContract/chunk', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ throw new Error(`청크 업로드 실패: ${response.statusText}`);
+ }
+
+ // 진행률 업데이트
+ const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
+ setUploadProgress(progress);
+
+ const result = await response.json();
+
+ // 마지막 청크인 경우 파일 경로 반환
+ if (chunkIndex === totalChunks - 1) {
+ return result;
+ }
+ } catch (error) {
+ console.error(`청크 ${chunkIndex} 업로드 오류:`, error);
+ throw error;
+ }
+ }
+ };
+
+ // 폼 제출 핸들러
+ async function onSubmit(formData: TemplateFormValues) {
+ setIsLoading(true);
+ try {
+ if (!formData.file) {
+ throw new Error("파일이 선택되지 않았습니다.");
+ }
+
+ // 고유 파일 ID 생성
+ const fileId = uuidv4();
+
+ // 파일 청크 업로드
+ const uploadResult = await uploadFileInChunks(formData.file, fileId);
+
+ if (!uploadResult.success) {
+ throw new Error("파일 업로드에 실패했습니다.");
+ }
+
+ // 메타데이터 저장
+ const saveResponse = await fetch('/api/upload/basicContract/complete', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ templateName: formData.templateName,
+ validityPeriod: formData.validityPeriod, // 유효기간 추가
+ status: formData.status,
+ fileName: uploadResult.fileName,
+ filePath: uploadResult.filePath,
+ }),
+ next: { tags: ["basic-contract-templates"] },
+ });
+
+ const saveResult = await saveResponse.json();
+
+ if (!saveResult.success) {
+ throw new Error("템플릿 정보 저장에 실패했습니다.");
+ }
+
+ toast.success('템플릿이 성공적으로 추가되었습니다.');
+ form.reset();
+ setSelectedFile(null);
+ setOpen(false);
+ setShowProgress(false);
+
+ router.refresh();
+ } catch (error) {
+ console.error("Submit error:", error);
+ toast.error("템플릿 추가 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ // 모달이 닫힐 때 폼 초기화
+ React.useEffect(() => {
+ if (!open) {
+ form.reset();
+ setSelectedFile(null);
+ setShowProgress(false);
+ setUploadProgress(0);
+ }
+ }, [open, form]);
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset();
+ }
+ setOpen(nextOpen);
+ }
+
+ // 유효기간 선택 옵션
+ const validityOptions = [
+ { value: "3", label: "3개월" },
+ { value: "6", label: "6개월" },
+ { value: "12", label: "1년" },
+ { value: "24", label: "2년" },
+ { value: "36", label: "3년" },
+ { value: "60", label: "5년" },
+ ];
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ 템플릿 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>새 기본계약서 템플릿 추가</DialogTitle>
+ <DialogDescription>
+ 템플릿 이름을 입력하고 계약서 파일을 업로드하세요.
+ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="templateName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 템플릿 이름 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="템플릿 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 계약 유효기간 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ value={field.value?.toString()}
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="유효기간을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {validityOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>
+ 계약서 파일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ accept={{
+ 'application/pdf': ['.pdf']
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile ? selectedFile.name : "PDF 파일을 여기에 드래그하세요"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
+ : "또는 클릭하여 PDF 파일을 선택하세요 (최대 100MB)"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {showProgress && (
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>업로드 진행률</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} />
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isLoading || !templateName || !validityPeriod || !file}
+ >
+ {isLoading ? "처리 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/basic-contract/template/basic-contract-template-columns.tsx b/lib/basic-contract/template/basic-contract-template-columns.tsx
new file mode 100644
index 00000000..b0486fe4
--- /dev/null
+++ b/lib/basic-contract/template/basic-contract-template-columns.tsx
@@ -0,0 +1,245 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Ellipsis, Paperclip } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { basicContractTemplateColumnsConfig } from "@/config/basicContractColumnsConfig"
+import { BasicContractTemplate } from "@/db/schema"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractTemplate> | null>>
+}
+
+/**
+ * 파일 다운로드 함수
+ */
+const handleFileDownload = (filePath: string, fileName: string) => {
+ try {
+ // 전체 URL 생성
+ const fullUrl = `${window.location.origin}${filePath}`;
+
+ // a 태그를 생성하여 다운로드 실행
+ const link = document.createElement('a');
+ link.href = fullUrl;
+ link.download = fileName; // 다운로드될 파일명 설정
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success("파일 다운로드를 시작합니다.");
+ } catch (error) {
+ console.error("파일 다운로드 오류:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ }
+};
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractTemplate>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<BasicContractTemplate> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 파일 다운로드 컬럼 (아이콘)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<BasicContractTemplate> = {
+ id: "download",
+ header: "",
+ cell: ({ row }) => {
+ const template = row.original;
+
+ return (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleFileDownload(template.filePath, template.fileName)}
+ title={`${template.fileName} 다운로드`}
+ className="hover:bg-muted"
+ >
+ <Paperclip className="h-4 w-4" />
+ <span className="sr-only">다운로드</span>
+ </Button>
+ );
+ },
+ maxSize: 30,
+ enableSorting: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<BasicContractTemplate> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractTemplate>[] }
+ const groupMap: Record<string, ColumnDef<BasicContractTemplate>[]> = {}
+
+ basicContractTemplateColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<BasicContractTemplate> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // 날짜 형식 처리
+ if (cfg.id === "createdAt" || cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDateTime(dateVal)
+ }
+
+ // Status 컬럼에 Badge 적용
+ if (cfg.id === "status") {
+ const status = row.getValue(cfg.id) as string
+ const isActive = status === "ACTIVE"
+
+ return (
+ <Badge
+ variant={isActive ? "default" : "secondary"}
+ >
+ {isActive ? "활성" : "비활성"}
+ </Badge>
+ )
+ }
+
+ // 나머지 컬럼은 그대로 값 표시
+ return row.getValue(cfg.id) ?? ""
+ },
+ minSize:80
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<BasicContractTemplate>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 5) 최종 컬럼 배열: select, download, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ downloadColumn, // 다운로드 컬럼 추가
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/basic-contract/template/basic-contract-template.tsx b/lib/basic-contract/template/basic-contract-template.tsx
new file mode 100644
index 00000000..0cca3a41
--- /dev/null
+++ b/lib/basic-contract/template/basic-contract-template.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import * as React from "react";
+import { DataTable } from "@/components/data-table/data-table";
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import type {
+ DataTableAdvancedFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { getBasicContractTemplates} from "../service";
+import { getColumns } from "./basic-contract-template-columns";
+import { DeleteTemplatesDialog } from "./delete-basicContract-dialog";
+import { UpdateTemplateSheet } from "./update-basicContract-sheet";
+import { TemplateTableToolbarActions } from "./basicContract-table-toolbar-actions";
+import { BasicContractTemplate } from "@/db/schema";
+
+
+interface BasicTemplateTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBasicContractTemplates>>,
+ ]
+ >
+}
+
+
+export function BasicContractTemplateTable({ promises }: BasicTemplateTableProps) {
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<BasicContractTemplate> | null>(null)
+
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // config 기반으로 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractTemplate>[] = [
+ { id: "templateName", label: "템플릿명", type: "text" },
+ {
+ id: "status", label: "상태", type: "select", options: [
+ { label: "활성", value: "ACTIVE" },
+ { label: "비활성", value: "INACTIVE" },
+ ]
+ },
+ { id: "fileName", label: "파일명", type: "text" },
+ { id: "createdAt", label: "생성일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ];
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ // filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <TemplateTableToolbarActions table={table} />
+
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteTemplatesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ templates={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+ <UpdateTemplateSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ template={rowAction?.row.original ?? null}
+ />
+
+ </>
+
+ );
+} \ No newline at end of file
diff --git a/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx
new file mode 100644
index 00000000..439fea26
--- /dev/null
+++ b/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import { type Task } from "@/db/schema/tasks"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { DeleteTemplatesDialog } from "./delete-basicContract-dialog"
+import { AddTemplateDialog } from "./add-basic-contract-template-dialog"
+import { BasicContractTemplate } from "@/db/schema"
+
+interface TemplateTableToolbarActionsProps {
+ table: Table<BasicContractTemplate>
+}
+
+export function TemplateTableToolbarActions({ table }: TemplateTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteTemplatesDialog
+ templates={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ <AddTemplateDialog/>
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "basic-contract-template-list",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/basic-contract/template/delete-basicContract-dialog.tsx b/lib/basic-contract/template/delete-basicContract-dialog.tsx
new file mode 100644
index 00000000..307bd9aa
--- /dev/null
+++ b/lib/basic-contract/template/delete-basicContract-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { removeTemplates } from "../service"
+import { BasicContractTemplate } from "@/db/schema"
+
+interface DeleteBasicContractsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ templates: Row<BasicContractTemplate>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteTemplatesDialog({
+ templates,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteBasicContractsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeTemplates({
+ ids: templates.map((template) => template.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Templates deleted")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({templates.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{templates.length}</span>
+ {templates.length === 1 ? " template" : " templates"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({templates.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{templates.length}</span>
+ {templates.length === 1 ? " template" : " templates"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx
new file mode 100644
index 00000000..2c6efc9b
--- /dev/null
+++ b/lib/basic-contract/template/update-basicContract-sheet.tsx
@@ -0,0 +1,300 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import * as z from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Input } from "@/components/ui/input"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput
+} from "@/components/ui/dropzone"
+import { updateTemplate } from "../service"
+import { BasicContractTemplate } from "@/db/schema"
+
+// 업데이트 템플릿 스키마 정의 (유효기간 필드 추가)
+export const updateTemplateSchema = z.object({
+ templateName: z.string().min(1, "템플릿 이름은 필수입니다."),
+ validityPeriod: z.coerce
+ .number({ invalid_type_error: "유효기간은 숫자여야 합니다." })
+ .int("유효기간은 정수여야 합니다.")
+ .min(1, "유효기간은 최소 1개월 이상이어야 합니다.")
+ .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.")
+ .default(12),
+ status: z.enum(["ACTIVE", "INACTIVE"], {
+ required_error: "상태는 필수 선택사항입니다.",
+ }),
+ file: z.instanceof(File, { message: "파일을 업로드해주세요." }).optional(),
+})
+
+export type UpdateTemplateSchema = z.infer<typeof updateTemplateSchema>
+
+interface UpdateTemplateSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ template: BasicContractTemplate | null
+ onSuccess?: () => void
+}
+
+export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTemplateSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
+
+ // 템플릿 데이터 확인을 위한 로그
+ console.log(template)
+
+ const form = useForm<UpdateTemplateSchema>({
+ resolver: zodResolver(updateTemplateSchema),
+ defaultValues: {
+ templateName: template?.templateName ?? "",
+ validityPeriod: template?.validityPeriod ?? 12, // 기본값 12개월
+ status: (template?.status as "ACTIVE" | "INACTIVE") || "ACTIVE"
+ },
+ mode: "onChange"
+ })
+
+ // 파일 선택 핸들러
+ const handleFileChange = (files: File[]) => {
+ if (files.length > 0) {
+ const file = files[0];
+ setSelectedFile(file);
+ form.setValue("file", file);
+ }
+ };
+
+ // 템플릿 변경 시 폼 값 업데이트
+ React.useEffect(() => {
+ if (template) {
+ form.reset({
+ templateName: template.templateName,
+ validityPeriod: template.validityPeriod ?? 12, // 기존 값이 없으면 기본값 12개월
+ status: template.status as "ACTIVE" | "INACTIVE",
+ });
+ }
+ }, [template, form]);
+
+ // 유효기간 선택 옵션
+ const validityOptions = [
+ { value: "3", label: "3개월" },
+ { value: "6", label: "6개월" },
+ { value: "12", label: "1년" },
+ { value: "24", label: "2년" },
+ { value: "36", label: "3년" },
+ { value: "60", label: "5년" },
+ ];
+
+ function onSubmit(input: UpdateTemplateSchema) {
+ startUpdateTransition(async () => {
+ if (!template) return
+
+ // FormData 객체 생성하여 파일과 데이터를 함께 전송
+ const formData = new FormData();
+ formData.append("templateName", input.templateName);
+ formData.append("validityPeriod", input.validityPeriod.toString()); // 유효기간 추가
+ formData.append("status", input.status);
+
+ if (input.file) {
+ formData.append("file", input.file);
+ }
+
+ try {
+ // 서비스 함수 호출
+ const { error } = await updateTemplate({
+ id: template.id,
+ formData,
+ });
+
+ if (error) {
+ toast.error(error);
+ return;
+ }
+
+ form.reset();
+ setSelectedFile(null);
+ props.onOpenChange?.(false);
+ toast.success("템플릿이 성공적으로 업데이트되었습니다.");
+ onSuccess?.();
+ } catch (error) {
+ console.error("Update error:", error);
+ toast.error("템플릿 업데이트 중 오류가 발생했습니다.");
+ }
+ });
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>템플릿 업데이트</SheetTitle>
+ <SheetDescription>
+ 템플릿 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ <FormField
+ control={form.control}
+ name="templateName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿 이름</FormLabel>
+ <FormControl>
+ <Input placeholder="템플릿 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약 유효기간</FormLabel>
+ <Select
+ value={field.value?.toString()}
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="유효기간을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {validityOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상태</FormLabel>
+ <Select
+ defaultValue={field.value}
+ onValueChange={field.onChange}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="템플릿 상태 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="ACTIVE">활성</SelectItem>
+ <SelectItem value="INACTIVE">비활성</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>템플릿 파일 (선택사항)</FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile
+ ? selectedFile.name
+ : template?.fileName
+ ? `현재 파일: ${template.fileName}`
+ : "새 파일을 드래그하세요"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / 1024).toFixed(2)} KB`
+ : "또는 클릭하여 파일을 선택하세요 (선택사항)"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ disabled={isUpdatePending || !form.formState.isValid}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file