summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-15 00:50:39 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-15 00:50:39 +0000
commit15c3ae6536c264db0508e4fc4aaa59c3e6d1af30 (patch)
tree8e2ad5e6a06999bfaaf00ab4ee30083a87050bad /lib
parentd5d27847a7eded9db59462fa744b76772bc9ce1d (diff)
(대표님) 기본계약 및 정기평가 작업사항, OCR 변경사항
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx387
-rw-r--r--lib/basic-contract/template/basic-contract-template-columns.tsx423
-rw-r--r--lib/basic-contract/template/update-basicContract-sheet.tsx426
-rw-r--r--lib/esg-check-list/table/esg-evaluations-table-columns.tsx27
-rw-r--r--lib/evaluation-criteria/service.ts2
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx7
-rw-r--r--lib/evaluation-target-list/service.ts30
-rw-r--r--lib/forms/services.ts67
-rw-r--r--lib/general-check-list/table/general-check-table-columns.tsx56
-rw-r--r--lib/sedp/sync-form.ts12
-rw-r--r--lib/sedp/sync-tag-types.ts3
-rw-r--r--lib/techsales-rfq/service.ts2
-rw-r--r--lib/welding/service.ts45
-rw-r--r--lib/welding/table/delete-ocr-rows-dialog.tsx151
-rw-r--r--lib/welding/table/exporft-ocr-data.ts4
-rw-r--r--lib/welding/table/ocr-table-columns.tsx4
-rw-r--r--lib/welding/table/ocr-table-toolbar-actions.tsx22
-rw-r--r--lib/welding/table/ocr-table.tsx12
18 files changed, 1290 insertions, 390 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
index cf0986f0..3a83d50f 100644
--- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
+++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
@@ -18,6 +18,8 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@@ -34,17 +36,31 @@ import {
DropzoneInput
} from "@/components/ui/dropzone";
import { Progress } from "@/components/ui/progress";
-import { useRouter } from "next/navigation"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { useRouter } from "next/navigation";
+import { BUSINESS_UNITS } from "@/config/basicContractColumnsConfig";
-// 유효기간 필드가 추가된 계약서 템플릿 스키마 정의
+// 업데이트된 계약서 템플릿 스키마 정의
const templateFormSchema = z.object({
+ templateCode: z.string()
+ .min(1, "템플릿 코드는 필수입니다.")
+ .max(50, "템플릿 코드는 50자 이하여야 합니다.")
+ .regex(/^[A-Z0-9_-]+$/, "템플릿 코드는 영문 대문자, 숫자, '_', '-'만 사용 가능합니다."),
templateName: z.string().min(1, "템플릿 이름은 필수입니다."),
- validityPeriod: z.coerce
- .number({ invalid_type_error: "유효기간은 숫자여야 합니다." })
- .int("유효기간은 정수여야 합니다.")
- .min(1, "유효기간은 최소 1개월 이상이어야 합니다.")
- .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.")
- .default(12),
+ revision: z.coerce.number().int().min(1).default(1),
+ legalReviewRequired: z.boolean().default(false),
+
+ // 적용 범위
+ shipBuildingApplicable: z.boolean().default(false),
+ windApplicable: z.boolean().default(false),
+ pcApplicable: z.boolean().default(false),
+ nbApplicable: z.boolean().default(false),
+ rcApplicable: z.boolean().default(false),
+ gyApplicable: z.boolean().default(false),
+ sysApplicable: z.boolean().default(false),
+ infraApplicable: z.boolean().default(false),
+
file: z
.instanceof(File, { message: "파일을 업로드해주세요." })
.refine((file) => file.size <= 100 * 1024 * 1024, {
@@ -55,6 +71,15 @@ const templateFormSchema = z.object({
{ message: "PDF 파일만 업로드 가능합니다." }
),
status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"),
+}).refine((data) => {
+ // 적어도 하나의 적용 범위는 선택되어야 함
+ const hasAnyScope = BUSINESS_UNITS.some(unit =>
+ data[unit.key as keyof typeof data] as boolean
+ );
+ return hasAnyScope;
+}, {
+ message: "적어도 하나의 적용 범위를 선택해야 합니다.",
+ path: ["shipBuildingApplicable"], // 에러를 첫 번째 체크박스에 표시
});
type TemplateFormValues = z.infer<typeof templateFormSchema>;
@@ -65,12 +90,22 @@ export function AddTemplateDialog() {
const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState(0);
const [showProgress, setShowProgress] = React.useState(false);
- const router = useRouter()
+ const router = useRouter();
// 기본값 설정
const defaultValues: Partial<TemplateFormValues> = {
+ templateCode: "",
templateName: "",
- validityPeriod: 12, // 기본값 1년
+ revision: 1,
+ legalReviewRequired: false,
+ shipBuildingApplicable: false,
+ windApplicable: false,
+ pcApplicable: false,
+ nbApplicable: false,
+ rcApplicable: false,
+ gyApplicable: false,
+ sysApplicable: false,
+ infraApplicable: false,
status: "ACTIVE",
};
@@ -81,11 +116,6 @@ export function AddTemplateDialog() {
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) {
@@ -95,6 +125,13 @@ export function AddTemplateDialog() {
}
};
+ // 모든 적용 범위 선택/해제
+ const handleSelectAllScopes = (checked: boolean) => {
+ BUSINESS_UNITS.forEach(unit => {
+ form.setValue(unit.key as keyof TemplateFormValues, checked);
+ });
+ };
+
// 청크 크기 설정 (1MB)
const CHUNK_SIZE = 1 * 1024 * 1024;
@@ -161,15 +198,25 @@ export function AddTemplateDialog() {
throw new Error("파일 업로드에 실패했습니다.");
}
- // 메타데이터 저장
+ // 메타데이터 저장 (업데이트된 필드들 포함)
const saveResponse = await fetch('/api/upload/basicContract/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
+ templateCode: formData.templateCode,
templateName: formData.templateName,
- validityPeriod: formData.validityPeriod, // 유효기간 추가
+ revision: formData.revision,
+ legalReviewRequired: formData.legalReviewRequired,
+ shipBuildingApplicable: formData.shipBuildingApplicable,
+ windApplicable: formData.windApplicable,
+ pcApplicable: formData.pcApplicable,
+ nbApplicable: formData.nbApplicable,
+ rcApplicable: formData.rcApplicable,
+ gyApplicable: formData.gyApplicable,
+ sysApplicable: formData.sysApplicable,
+ infraApplicable: formData.infraApplicable,
status: formData.status,
fileName: uploadResult.fileName,
filePath: uploadResult.filePath,
@@ -215,15 +262,10 @@ export function AddTemplateDialog() {
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년" },
- ];
+ // 현재 선택된 적용 범위 수
+ const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
+ form.watch(unit.key as keyof TemplateFormValues)
+ ).length;
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
@@ -232,108 +274,215 @@ export function AddTemplateDialog() {
템플릿 추가
</Button>
</DialogTrigger>
- <DialogContent className="sm:max-w-[500px]">
+ <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<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>
- )}
- />
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="templateCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 템플릿 코드 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="TEMPLATE_001"
+ {...field}
+ style={{ textTransform: 'uppercase' }}
+ onChange={(e) => field.onChange(e.target.value.toUpperCase())}
+ />
+ </FormControl>
+ <FormDescription>
+ 영문 대문자, 숫자, '_', '-'만 사용 가능
+ </FormDescription>
+ <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="revision"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>리비전</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ {...field}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
+ />
+ </FormControl>
+ <FormDescription>
+ 템플릿 버전 (기본값: 1)
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
- <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>
+ <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="legalReviewRequired"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
+ <div className="space-y-0.5">
+ <FormLabel>법무검토 필요</FormLabel>
+ <FormDescription>
+ 법무팀 검토가 필요한 템플릿인지 설정
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 적용 범위 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">적용 범위</CardTitle>
+ <CardDescription>
+ 이 템플릿이 적용될 사업부를 선택하세요. ({selectedScopesCount}개 선택됨)
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="select-all"
+ checked={selectedScopesCount === BUSINESS_UNITS.length}
+ onCheckedChange={handleSelectAllScopes}
+ />
+ <label htmlFor="select-all" className="text-sm font-medium">
+ 전체 선택
+ </label>
</div>
- <Progress value={uploadProgress} />
- </div>
- )}
+
+ <Separator />
+
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+ {BUSINESS_UNITS.map((unit) => (
+ <FormField
+ key={unit.key}
+ control={form.control}
+ name={unit.key as keyof TemplateFormValues}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value as boolean}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel className="text-sm font-normal">
+ {unit.label}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ ))}
+ </div>
+
+ {form.formState.errors.shipBuildingApplicable && (
+ <p className="text-sm text-destructive">
+ {form.formState.errors.shipBuildingApplicable.message}
+ </p>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 파일 업로드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">파일 업로드</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <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 mt-4">
+ <div className="flex justify-between text-sm">
+ <span>업로드 진행률</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} />
+ </div>
+ )}
+ </CardContent>
+ </Card>
<DialogFooter>
<Button
@@ -346,7 +495,7 @@ export function AddTemplateDialog() {
</Button>
<Button
type="submit"
- disabled={isLoading || !templateName || !validityPeriod || !file}
+ disabled={isLoading || !form.formState.isValid}
>
{isLoading ? "처리 중..." : "추가"}
</Button>
diff --git a/lib/basic-contract/template/basic-contract-template-columns.tsx b/lib/basic-contract/template/basic-contract-template-columns.tsx
index 5f4433d1..3be46791 100644
--- a/lib/basic-contract/template/basic-contract-template-columns.tsx
+++ b/lib/basic-contract/template/basic-contract-template-columns.tsx
@@ -3,7 +3,7 @@
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 { Download, Ellipsis, Paperclip, CheckCircle, XCircle } from "lucide-react"
import { toast } from "sonner"
import { getErrorMessage } from "@/lib/handle-error"
@@ -24,9 +24,10 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { basicContractTemplateColumnsConfig } from "@/config/basicContractColumnsConfig"
+import { scopeHelpers } from "@/config/basicContractColumnsConfig"
import { BasicContractTemplate } from "@/db/schema"
interface GetColumnsProps {
@@ -40,7 +41,7 @@ const handleFileDownload = (filePath: string, fileName: string) => {
try {
// 전체 URL 생성
const fullUrl = `${window.location.origin}${filePath}`;
-
+
// a 태그를 생성하여 다운로드 실행
const link = document.createElement('a');
link.href = fullUrl;
@@ -48,7 +49,7 @@ const handleFileDownload = (filePath: string, fileName: string) => {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
-
+
toast.success("파일 다운로드를 시작합니다.");
} catch (error) {
console.error("파일 다운로드 오류:", error);
@@ -97,7 +98,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
header: "",
cell: ({ row }) => {
const template = row.original;
-
+
return (
<Button
variant="ghost"
@@ -123,6 +124,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
enableHiding: false,
cell: function Cell({ row }) {
const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const template = row.original;
return (
<DropdownMenu>
@@ -142,6 +144,22 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
Edit
</DropdownMenuItem>
+ {template.status === 'ACTIVE' && (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "dispose" })}
+ >
+ Dispose
+ </DropdownMenuItem>
+ )}
+
+ {template.status === 'DISPOSED' && template.disposedAt && (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "restore" })}
+ >
+ Restore
+ </DropdownMenuItem>
+ )}
+
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "delete" })}
@@ -157,88 +175,341 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
}
// ----------------------------------------------------------------
- // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // 4) 직접 컬럼 정의 (그룹별로 중첩)
// ----------------------------------------------------------------
- // 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, "KR")
- }
-
- // 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) ?? ""
+ // 기본 정보 그룹
+ const basicInfoColumns: ColumnDef<BasicContractTemplate>[] = [
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />,
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+ const isActive = status === "ACTIVE";
+ return (
+ <Badge variant={isActive ? "default" : "secondary"}>
+ {isActive ? "활성" : "폐기"}
+ </Badge>
+ );
},
- minSize:80
- }
+ size: 80,
+ enableResizing: true,
+ },
- groupMap[groupName].push(childCol)
- })
+ {
+ accessorKey: "templateName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약문서명" />,
+ cell: ({ row }) => {
+ const template = row.original;
+ const scopeText = scopeHelpers.getScopeDisplayText(template);
+ return (
+ <div className="flex flex-col min-w-0">
+ <span className="truncate">{template.templateName}</span>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="text-xs text-muted-foreground cursor-help truncate">
+ {scopeText}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="space-y-1">
+ <p className="font-medium">적용 범위:</p>
+ {scopeHelpers.getApplicableScopeLabels(template).map(label => (
+ <p key={label} className="text-xs">• {label}</p>
+ ))}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ size: 250,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "templateCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약문서 번호" />,
+ cell: ({ row }) => {
+ const template = row.original;
+ return (
+ <span className="font-medium">{template.templateCode}</span>
+ );
+ },
+ size: 120,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "revision",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Rev." />,
+ cell: ({ row }) => {
+ const template = row.original;
+ return (
+ <span className="text-xs text-muted-foreground">v{template.revision}</span>
+ );
+ },
+ size: 60,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "legalReviewRequired",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="법무검토" />,
+ cell: ({ row }) => {
+ const required = row.getValue("legalReviewRequired") as boolean;
+ return (
+ <Badge variant={required ? "destructive" : "secondary"}>
+ {required ? "필요" : "불필요"}
+ </Badge>
+ );
+ },
+ size: 100,
+ enableResizing: true,
+ },
+ ];
- // ----------------------------------------------------------------
- // 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,
- })
- }
- })
+ // 적용 범위 그룹
+ const scopeColumns: ColumnDef<BasicContractTemplate>[] = [
+ {
+ accessorKey: "shipBuildingApplicable",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="조선해양" />,
+ cell: ({ row }) => {
+ const applicable = row.getValue("shipBuildingApplicable") as boolean;
+ return (
+ <div className="flex justify-center">
+ {applicable ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-gray-300" />
+ )}
+ </div>
+ );
+ },
+ size: 80,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "windApplicable",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="풍력" />,
+ cell: ({ row }) => {
+ const applicable = row.getValue("windApplicable") as boolean;
+ return (
+ <div className="flex justify-center">
+ {applicable ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-gray-300" />
+ )}
+ </div>
+ );
+ },
+ size: 60,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "pcApplicable",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PC" />,
+ cell: ({ row }) => {
+ const applicable = row.getValue("pcApplicable") as boolean;
+ return (
+ <div className="flex justify-center">
+ {applicable ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-gray-300" />
+ )}
+ </div>
+ );
+ },
+ size: 50,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "nbApplicable",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="NB" />,
+ cell: ({ row }) => {
+ const applicable = row.getValue("nbApplicable") as boolean;
+ return (
+ <div className="flex justify-center">
+ {applicable ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-gray-300" />
+ )}
+ </div>
+ );
+ },
+ size: 50,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "rcApplicable",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RC" />,
+ cell: ({ row }) => {
+ const applicable = row.getValue("rcApplicable") as boolean;
+ return (
+ <div className="flex justify-center">
+ {applicable ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-gray-300" />
+ )}
+ </div>
+ );
+ },
+ size: 50,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "gyApplicable",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="GY" />,
+ cell: ({ row }) => {
+ const applicable = row.getValue("gyApplicable") as boolean;
+ return (
+ <div className="flex justify-center">
+ {applicable ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-gray-300" />
+ )}
+ </div>
+ );
+ },
+ size: 50,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "sysApplicable",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="S&Sys" />,
+ cell: ({ row }) => {
+ const applicable = row.getValue("sysApplicable") as boolean;
+ return (
+ <div className="flex justify-center">
+ {applicable ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-gray-300" />
+ )}
+ </div>
+ );
+ },
+ size: 60,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "infraApplicable",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Infra" />,
+ cell: ({ row }) => {
+ const applicable = row.getValue("infraApplicable") as boolean;
+ return (
+ <div className="flex justify-center">
+ {applicable ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-gray-300" />
+ )}
+ </div>
+ );
+ },
+ size: 60,
+ enableResizing: true,
+ },
+ ];
+
+ // 파일 정보 그룹
+ const fileInfoColumns: ColumnDef<BasicContractTemplate>[] = [
+ {
+ accessorKey: "fileName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="파일명" />,
+ cell: ({ row }) => {
+ const fileName = row.getValue("fileName") as string;
+ return (
+ <div className="min-w-0 max-w-full">
+ <span className="block truncate" title={fileName}>
+ {fileName}
+ </span>
+ </div>
+ );
+ },
+ size: 200,
+ enableResizing: true,
+ },
+ ];
+
+ // 감사 정보 그룹
+ const auditColumns: ColumnDef<BasicContractTemplate>[] = [
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />,
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date;
+ return date ? formatDateTime(date, "KR") : "-";
+ },
+ size: 120,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />,
+ cell: ({ row }) => {
+ const date = row.getValue("updatedAt") as Date;
+ return date ? formatDateTime(date, "KR") : "-";
+ },
+ size: 120,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "disposedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐기일" />,
+ cell: ({ row }) => {
+ const date = row.getValue("disposedAt") as Date;
+ return date ? formatDateTime(date, "KR") : "-";
+ },
+ size: 120,
+ enableResizing: true,
+ },
+ {
+ accessorKey: "restoredAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="복구일" />,
+ cell: ({ row }) => {
+ const date = row.getValue("restoredAt") as Date;
+ return date ? formatDateTime(date, "KR") : "-";
+ },
+ size: 120,
+ enableResizing: true,
+ },
+ ];
+
+ // 중첩 컬럼 그룹 생성
+ const nestedColumns: ColumnDef<BasicContractTemplate>[] = [
+ {
+ id: "기본 정보",
+ header: "기본 정보",
+ columns: basicInfoColumns,
+ },
+ {
+ id: "적용 범위",
+ header: "적용 범위",
+ columns: scopeColumns,
+ },
+ {
+ id: "파일 정보",
+ header: "파일 정보",
+ columns: fileInfoColumns,
+ },
+ {
+ id: "감사 정보",
+ header: "감사 정보",
+ columns: auditColumns,
+ },
+ ]
// ----------------------------------------------------------------
// 5) 최종 컬럼 배열: select, download, nestedColumns, actions
// ----------------------------------------------------------------
return [
selectColumn,
- downloadColumn, // 다운로드 컬럼 추가
+ downloadColumn,
...nestedColumns,
actionsColumn,
]
diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx
index 2c6efc9b..810e1b77 100644
--- a/lib/basic-contract/template/update-basicContract-sheet.tsx
+++ b/lib/basic-contract/template/update-basicContract-sheet.tsx
@@ -8,6 +8,8 @@ import { toast } from "sonner"
import * as z from "zod"
import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Switch } from "@/components/ui/switch"
import {
Form,
FormControl,
@@ -43,23 +45,44 @@ import {
DropzoneDescription,
DropzoneInput
} from "@/components/ui/dropzone"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Separator } from "@/components/ui/separator"
+import { Badge } from "@/components/ui/badge"
import { updateTemplate } from "../service"
import { BasicContractTemplate } from "@/db/schema"
+import { BUSINESS_UNITS, scopeHelpers } from "@/config/basicContractColumnsConfig"
-// 업데이트 템플릿 스키마 정의 (유효기간 필드 추가)
+// 업데이트 템플릿 스키마 정의
export const updateTemplateSchema = z.object({
+ templateCode: z.string().min(1, "템플릿 코드는 필수입니다."), // readonly로 처리
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"], {
+ revision: z.coerce.number().int().min(1, "리비전은 1 이상이어야 합니다."),
+ legalReviewRequired: z.boolean(),
+
+ // 적용 범위
+ shipBuildingApplicable: z.boolean(),
+ windApplicable: z.boolean(),
+ pcApplicable: z.boolean(),
+ nbApplicable: z.boolean(),
+ rcApplicable: z.boolean(),
+ gyApplicable: z.boolean(),
+ sysApplicable: z.boolean(),
+ infraApplicable: z.boolean(),
+
+ status: z.enum(["ACTIVE", "DISPOSED"], {
required_error: "상태는 필수 선택사항입니다.",
}),
file: z.instanceof(File, { message: "파일을 업로드해주세요." }).optional(),
-})
+}).refine((data) => {
+ // 적어도 하나의 적용 범위는 선택되어야 함
+ const hasAnyScope = BUSINESS_UNITS.some(unit =>
+ data[unit.key as keyof typeof data] as boolean
+ );
+ return hasAnyScope;
+}, {
+ message: "적어도 하나의 적용 범위를 선택해야 합니다.",
+ path: ["shipBuildingApplicable"],
+});
export type UpdateTemplateSchema = z.infer<typeof updateTemplateSchema>
@@ -73,15 +96,22 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
const [isUpdatePending, startUpdateTransition] = React.useTransition()
const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
- // 템플릿 데이터 확인을 위한 로그
- console.log(template)
-
const form = useForm<UpdateTemplateSchema>({
resolver: zodResolver(updateTemplateSchema),
defaultValues: {
+ templateCode: template?.templateCode ?? "",
templateName: template?.templateName ?? "",
- validityPeriod: template?.validityPeriod ?? 12, // 기본값 12개월
- status: (template?.status as "ACTIVE" | "INACTIVE") || "ACTIVE"
+ revision: template?.revision ?? 1,
+ legalReviewRequired: template?.legalReviewRequired ?? false,
+ shipBuildingApplicable: template?.shipBuildingApplicable ?? false,
+ windApplicable: template?.windApplicable ?? false,
+ pcApplicable: template?.pcApplicable ?? false,
+ nbApplicable: template?.nbApplicable ?? false,
+ rcApplicable: template?.rcApplicable ?? false,
+ gyApplicable: template?.gyApplicable ?? false,
+ sysApplicable: template?.sysApplicable ?? false,
+ infraApplicable: template?.infraApplicable ?? false,
+ status: (template?.status as "ACTIVE" | "DISPOSED") || "ACTIVE"
},
mode: "onChange"
})
@@ -95,26 +125,38 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
}
};
+ // 모든 적용 범위 선택/해제
+ const handleSelectAllScopes = (checked: boolean) => {
+ BUSINESS_UNITS.forEach(unit => {
+ form.setValue(unit.key as keyof UpdateTemplateSchema, checked);
+ });
+ };
+
// 템플릿 변경 시 폼 값 업데이트
React.useEffect(() => {
if (template) {
form.reset({
+ templateCode: template.templateCode,
templateName: template.templateName,
- validityPeriod: template.validityPeriod ?? 12, // 기존 값이 없으면 기본값 12개월
- status: template.status as "ACTIVE" | "INACTIVE",
+ revision: template.revision ?? 1,
+ legalReviewRequired: template.legalReviewRequired ?? false,
+ shipBuildingApplicable: template.shipBuildingApplicable ?? false,
+ windApplicable: template.windApplicable ?? false,
+ pcApplicable: template.pcApplicable ?? false,
+ nbApplicable: template.nbApplicable ?? false,
+ rcApplicable: template.rcApplicable ?? false,
+ gyApplicable: template.gyApplicable ?? false,
+ sysApplicable: template.sysApplicable ?? false,
+ infraApplicable: template.infraApplicable ?? false,
+ status: template.status as "ACTIVE" | "DISPOSED",
});
}
}, [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년" },
- ];
+ // 현재 선택된 적용 범위 수
+ const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
+ form.watch(unit.key as keyof UpdateTemplateSchema)
+ ).length;
function onSubmit(input: UpdateTemplateSchema) {
startUpdateTransition(async () => {
@@ -122,8 +164,17 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
// FormData 객체 생성하여 파일과 데이터를 함께 전송
const formData = new FormData();
+ formData.append("templateCode", input.templateCode);
formData.append("templateName", input.templateName);
- formData.append("validityPeriod", input.validityPeriod.toString()); // 유효기간 추가
+ formData.append("revision", input.revision.toString());
+ formData.append("legalReviewRequired", input.legalReviewRequired.toString());
+
+ // 적용 범위 추가
+ BUSINESS_UNITS.forEach(unit => {
+ const value = input[unit.key as keyof UpdateTemplateSchema] as boolean;
+ formData.append(unit.key, value.toString());
+ });
+
formData.append("status", input.status);
if (input.file) {
@@ -154,124 +205,241 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem
});
}
+ if (!template) return null;
+
return (
<Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetContent className="flex flex-col gap-6 sm:max-w-[600px] overflow-y-auto">
<SheetHeader className="text-left">
<SheetTitle>템플릿 업데이트</SheetTitle>
<SheetDescription>
템플릿 정보를 수정하고 변경사항을 저장하세요
</SheetDescription>
</SheetHeader>
+
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4"
+ className="flex flex-col gap-6"
>
- <FormField
- control={form.control}
- name="templateName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>템플릿 이름</FormLabel>
- <FormControl>
- <Input placeholder="템플릿 이름을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">기본 정보</CardTitle>
+ <CardDescription>
+ 현재 적용 범위: {scopeHelpers.getScopeDisplayText(template)}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="templateCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿 코드</FormLabel>
+ <FormControl>
+ <Input
+ {...field}
+ readOnly
+ className="bg-muted"
+ />
+ </FormControl>
+ <FormDescription>
+ 템플릿 코드는 수정할 수 없습니다.
+ </FormDescription>
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="revision"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>리비전</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ {...field}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
+ />
+ </FormControl>
+ <FormDescription>
+ 템플릿 버전을 업데이트하세요.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <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>
- )}
- />
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <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="DISPOSED">폐기</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="legalReviewRequired"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
+ <div className="space-y-0.5">
+ <FormLabel>법무검토 필요</FormLabel>
+ <FormDescription>
+ 법무팀 검토가 필요한 템플릿인지 설정
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
- <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>
- )}
- />
+ {/* 적용 범위 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">적용 범위</CardTitle>
+ <CardDescription>
+ 이 템플릿이 적용될 사업부를 선택하세요. ({selectedScopesCount}개 선택됨)
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="select-all"
+ checked={selectedScopesCount === BUSINESS_UNITS.length}
+ onCheckedChange={handleSelectAllScopes}
+ />
+ <label htmlFor="select-all" className="text-sm font-medium">
+ 전체 선택
+ </label>
+ </div>
+
+ <Separator />
+
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+ {BUSINESS_UNITS.map((unit) => (
+ <FormField
+ key={unit.key}
+ control={form.control}
+ name={unit.key as keyof UpdateTemplateSchema}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value as boolean}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel className="text-sm font-normal">
+ {unit.label}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ ))}
+ </div>
+
+ {form.formState.errors.shipBuildingApplicable && (
+ <p className="text-sm text-destructive">
+ {form.formState.errors.shipBuildingApplicable.message}
+ </p>
+ )}
+ </CardContent>
+ </Card>
- <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>
- )}
- />
+ {/* 파일 업데이트 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">파일 업데이트</CardTitle>
+ <CardDescription>
+ 현재 파일: {template.fileName}
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>템플릿 파일 (선택사항)</FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ accept={{
+ 'application/pdf': ['.pdf']
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile
+ ? selectedFile.name
+ : "새 파일을 드래그하세요 (선택사항)"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
+ : "또는 클릭하여 파일을 선택하세요"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
<SheetFooter className="gap-2 pt-2 sm:space-x-0">
<SheetClose asChild>
diff --git a/lib/esg-check-list/table/esg-evaluations-table-columns.tsx b/lib/esg-check-list/table/esg-evaluations-table-columns.tsx
index 48139c75..af0b87af 100644
--- a/lib/esg-check-list/table/esg-evaluations-table-columns.tsx
+++ b/lib/esg-check-list/table/esg-evaluations-table-columns.tsx
@@ -18,6 +18,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Badge } from "@/components/ui/badge"
@@ -232,29 +234,34 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<EsgEval
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
- <Button variant="ghost" size="icon">
- <Ellipsis className="h-4 w-4" />
+ <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">
+ <DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
- onClick={() => setRowAction({ row, type: "view" })}
+ onSelect={() => setRowAction({ row, type: "view" })}
>
- <InfoIcon className="mr-2 h-4 w-4" />
+ {/* <Eye className="mr-2 size-4" /> */}
상세보기
</DropdownMenuItem>
<DropdownMenuItem
- onClick={() => setRowAction({ row, type: "update" })}
+ onSelect={() => setRowAction({ row, type: "update" })}
>
- <PenToolIcon className="mr-2 h-4 w-4" />
+ {/* <Edit className="mr-2 size-4" /> */}
수정하기
</DropdownMenuItem>
+ <DropdownMenuSeparator />
<DropdownMenuItem
- onClick={() => setRowAction({ row, type: "delete" })}
- className="text-destructive"
+ onSelect={() => setRowAction({ row, type: "delete" })}
>
- <TrashIcon className="mr-2 h-4 w-4" />
+ {/* <Trash2 className="mr-2 size-4" /> */}
삭제하기
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
diff --git a/lib/evaluation-criteria/service.ts b/lib/evaluation-criteria/service.ts
index 19f2dd81..9cb0126f 100644
--- a/lib/evaluation-criteria/service.ts
+++ b/lib/evaluation-criteria/service.ts
@@ -781,7 +781,7 @@ async function importRegEvalCriteriaExcel(file: File): Promise<{
const itemValue = REG_EVAL_CRITERIA_ITEM.find(item => item.label === rowData.item)?.value || rowData.item;
// 데이터 그룹화
- const groupKey = `${categoryValue}-${category2Value}-${itemValue}-${rowData.classification}`;
+ const groupKey = `${categoryValue}-${category2Value}-${itemValue}-${rowData.classification}-${rowData.range || ''}-${rowData.remarks || ''}`;
if (!groupedData.has(groupKey)) {
groupedData.set(groupKey, []);
}
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx
index d48e097b..2b7cbfe7 100644
--- a/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx
@@ -371,21 +371,20 @@ function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RegEvalCriteri
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "view" })}
>
- <Eye className="mr-2 size-4" />
+ {/* <Eye className="mr-2 size-4" /> */}
상세보기
</DropdownMenuItem>
- <DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "update" })}
>
- <Edit className="mr-2 size-4" />
+ {/* <Edit className="mr-2 size-4" /> */}
수정하기
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "delete" })}
>
- <Trash2 className="mr-2 size-4" />
+ {/* <Trash2 className="mr-2 size-4" /> */}
삭제하기
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
</DropdownMenuItem>
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts
index 6de00329..4559374b 100644
--- a/lib/evaluation-target-list/service.ts
+++ b/lib/evaluation-target-list/service.ts
@@ -276,6 +276,8 @@ export async function createEvaluationTarget(
) {
console.log(input, "input")
try {
+ const session = await getServerSession(authOptions)
+
return await db.transaction(async (tx) => {
// 벤더 정보 조회
const vendor = await tx
@@ -352,18 +354,26 @@ export async function createEvaluationTarget(
.from(users)
.where(inArray(users.id, reviewerIds)); // sql 대신 inArray 사용
- const reviewerAssignments: typeof evaluationTargetReviewers.$inferInsert[] =
- input.reviewers.map(r => {
- const info = reviewerInfos.find(i => i.id === r.reviewerUserId);
- return {
+ const reviewerAssignments: typeof evaluationTargetReviewers.$inferInsert[] = [
+ ...input.reviewers.map(r => {
+ const info = reviewerInfos.find(i => i.id === r.reviewerUserId);
+ return {
+ evaluationTargetId,
+ departmentCode: r.departmentCode,
+ departmentNameFrom: info?.departmentName ?? "TEST 부서",
+ reviewerUserId: r.reviewerUserId,
+ assignedBy: createdBy,
+ };
+ }),
+ // session user 추가
+ {
evaluationTargetId,
- departmentCode: r.departmentCode,
- departmentNameFrom: info?.departmentName ?? "TEST 부서",
- reviewerUserId: r.reviewerUserId,
+ departmentCode: "admin",
+ departmentNameFrom: "정기평가 관리자",
+ reviewerUserId: Number(session.user.id),
assignedBy: createdBy,
- };
- });
-
+ }
+ ];
await tx.insert(evaluationTargetReviewers).values(reviewerAssignments);
}
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index 7c1219d2..02333095 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -1072,6 +1072,7 @@ async function transformDataToSEDPFormat(
formCode: string,
objectCode: string,
projectNo: string,
+ contractItemId: number, // Add contractItemId parameter
designerNo: string = "253213"
): Promise<SEDPDataItem[]> {
// Create a map for quick column lookup
@@ -1092,10 +1093,67 @@ async function transformDataToSEDPFormat(
// Cache for UOM factors to avoid duplicate API calls
const uomFactorCache = new Map<string, number>();
+ // Cache for packageCode to avoid duplicate DB queries for same tag
+ const packageCodeCache = new Map<string, string>();
+
// Transform each row
const transformedItems = [];
for (const row of tableData) {
+ // Get packageCode for this specific tag
+ let packageCode = formCode; // fallback to formCode
+
+ if (row.TAG_NO && contractItemId) {
+ // Check cache first
+ const cacheKey = `${contractItemId}-${row.TAG_NO}`;
+
+ if (packageCodeCache.has(cacheKey)) {
+ packageCode = packageCodeCache.get(cacheKey)!;
+ } else {
+ try {
+ // Query to get packageCode for this specific tag
+ const tagResult = await db.query.tags.findFirst({
+ where: and(
+ eq(tags.contractItemId, contractItemId),
+ eq(tags.tagNo, row.TAG_NO)
+ )
+ });
+
+ if (tagResult) {
+ // Get the contract item
+ const contractItemResult = await db.query.contractItems.findFirst({
+ where: eq(contractItems.id, tagResult.contractItemId)
+ });
+
+ if (contractItemResult) {
+ // Get the first item with this itemId
+ const itemResult = await db.query.items.findFirst({
+ where: eq(items.id, contractItemResult.itemId)
+ });
+
+ if (itemResult && itemResult.packageCode) {
+ packageCode = itemResult.packageCode;
+ console.log(`Found packageCode for tag ${row.TAG_NO}: ${packageCode}`);
+ } else {
+ console.warn(`No item found for contractItem.itemId: ${contractItemResult.itemId}, using fallback`);
+ }
+ } else {
+ console.warn(`No contractItem found for tag ${row.TAG_NO}, using fallback`);
+ }
+ } else {
+ console.warn(`No tag found for contractItemId: ${contractItemId}, tagNo: ${row.TAG_NO}, using fallback`);
+ }
+
+ // Cache the result (even if it's the fallback value)
+ packageCodeCache.set(cacheKey, packageCode);
+ } catch (error) {
+ console.error(`Error fetching packageCode for tag ${row.TAG_NO}:`, error);
+ // Use fallback value and cache it
+ packageCodeCache.set(cacheKey, packageCode);
+ }
+ }
+ }
+
// Create base SEDP item with required fields
const sedpItem: SEDPDataItem = {
TAG_NO: row.TAG_NO || "",
@@ -1110,7 +1168,7 @@ async function transformDataToSEDPFormat(
LAST_REV_YN: true,
CRTER_NO: designerNo,
CHGER_NO: designerNo,
- TYPE: formCode,
+ TYPE: packageCode, // Use packageCode instead of formCode
PROJ_NO: projectNo,
REV_NO: "00",
CRTE_DTM: currentTimestamp,
@@ -1202,19 +1260,19 @@ export async function transformFormDataToSEDP(
formCode: string,
objectCode: string,
projectNo: string,
+ contractItemId: number, // Add contractItemId parameter
designerNo: string = "253213"
): Promise<SEDPDataItem[]> {
- // Use the utility function within the async Server Action
return transformDataToSEDPFormat(
tableData,
columnsJSON,
formCode,
objectCode,
projectNo,
+ contractItemId, // Pass contractItemId
designerNo
);
}
-
/**
* Get project code by project ID
*/
@@ -1330,7 +1388,8 @@ export async function sendFormDataToSEDP(
columns,
formCode,
objectCode,
- projectCode
+ projectCode,
+ contractItemId // Add contractItemId parameter
);
// 4. Send to SEDP API
diff --git a/lib/general-check-list/table/general-check-table-columns.tsx b/lib/general-check-list/table/general-check-table-columns.tsx
index c764686d..e95855a9 100644
--- a/lib/general-check-list/table/general-check-table-columns.tsx
+++ b/lib/general-check-list/table/general-check-table-columns.tsx
@@ -8,6 +8,7 @@ import { Ellipsis, Pencil, Trash } from "lucide-react";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { DeleteEvaluationsDialog } from "./delete-check-lists-dialog";
import { EditEvaluationSheet } from "./update-check-list-sheet";
+import { DropdownMenu, DropdownMenuShortcut, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
export interface GeneralEvaluationRow {
@@ -95,42 +96,47 @@ export function getGeneralEvaluationColumns(): ColumnDef<GeneralEvaluationRow>[]
{
id: "actions",
enableHiding: false,
- size: 40,
- minSize:80,
- cell: ({ row }) => {
+ size: 80,
+ cell: function Cell({ row }) {
const record = row.original;
const [openEdit, setOpenEdit] = React.useState(false);
const [openDelete, setOpenDelete] = React.useState(false);
return (
- <>
- <Button
- variant="ghost"
- size="icon"
- className="size-8"
- onClick={() => setOpenEdit(true)}
- aria-label="edit"
- >
- <Pencil className="size-4" />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- className="size-8"
- onClick={() => setOpenDelete(true)}
- aria-label="delete"
- >
- <Trash className="size-4" />
- </Button>
-
- <EditEvaluationSheet open={openEdit} onOpenChange={setOpenEdit} evaluation={record} />
+ <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={() => setOpenEdit(true)}
+ >
+ {/* <Pencil className="mr-2 size-4" /> */}
+ 수정하기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setOpenDelete(true)}
+ >
+ {/* <Trash className="mr-2 size-4" /> */}
+ 삭제하기
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ <EditEvaluationSheet open={openEdit} onOpenChange={setOpenEdit} evaluation={record} />
<DeleteEvaluationsDialog
open={openDelete}
onOpenChange={setOpenDelete}
evaluations={[record]}
showTrigger={false}
/>
- </>
+ </DropdownMenu>
);
},
},
diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts
index 0606f4a9..87de4645 100644
--- a/lib/sedp/sync-form.ts
+++ b/lib/sedp/sync-form.ts
@@ -891,12 +891,12 @@ async function getContractItemsByItemCodes(itemCodes: string[], projectId: numbe
for (const item of itemRecords) {
// itemCode가 null이 아닌 경우에만 처리
- if (item.itemCode) {
+ if (item.packageCode) {
const matchedContractItems = contractItemRecords.filter(ci => ci.itemId === item.id);
if (matchedContractItems.length > 0) {
// 일치하는 모든 contractItem을 배열로 저장
const contractItemIds = matchedContractItems.map(ci => ci.id);
- itemCodeToContractItemIds.set(item.itemCode, contractItemIds);
+ itemCodeToContractItemIds.set(item.packageCode, contractItemIds);
}
}
}
@@ -1062,17 +1062,17 @@ export async function saveFormMappingsAndMetas(
await tx.delete(formMetas).where(eq(formMetas.projectId, projectId));
if (contractItemIdsWithForms.size) await tx.delete(forms).where(inArray(forms.contractItemId, [...contractItemIdsWithForms]));
- const savedMappings = mappingsToSave.length ? await tx.insert(tagTypeClassFormMappings).values(mappingsToSave).returning({ id: tagTypeClassFormMappings.id, formCode: tagTypeClassFormMappings.formCode }) : [];
+ const savedMappings = mappingsToSave.length ? await tx.insert(tagTypeClassFormMappings).values(mappingsToSave).onConflictDoNothing().returning({ id: tagTypeClassFormMappings.id, formCode: tagTypeClassFormMappings.formCode }) : [];
totalSaved += mappingsToSave.length;
if (savedMappings.length) {
const rows: any[] = [];
savedMappings.forEach(m => (templateDataByFormCode.get(m.formCode) || []).forEach(t => rows.push({ formMappingId: m.id, tmplId: t.TMPL_ID, name: t.NAME, tmplType: t.TMPL_TYPE, sprLstSetup: t.SPR_LST_SETUP, grdLstSetup: t.GRD_LST_SETUP, sprItmLstSetup: t.SPR_ITM_LST_SETUP, description: `Template for form ${m.formCode}`, isActive: true, createdAt: new Date(), updatedAt: new Date() })));
- if (rows.length) { await tx.insert(templateItems).values(rows); totalSaved += rows.length; }
+ if (rows.length) { await tx.insert(templateItems).values(rows).onConflictDoNothing(); totalSaved += rows.length; }
}
- if (formMetasToSave.length) { await tx.insert(formMetas).values(formMetasToSave); totalSaved += formMetasToSave.length; }
- if (formsToSave.length) { await tx.insert(forms).values(formsToSave); totalSaved += formsToSave.length; }
+ if (formMetasToSave.length) { await tx.insert(formMetas).values(formMetasToSave).onConflictDoNothing(); totalSaved += formMetasToSave.length; }
+ if (formsToSave.length) { await tx.insert(forms).values(formsToSave).onConflictDoNothing(); totalSaved += formsToSave.length; }
});
return totalSaved;
diff --git a/lib/sedp/sync-tag-types.ts b/lib/sedp/sync-tag-types.ts
index abed9021..ac259cbb 100644
--- a/lib/sedp/sync-tag-types.ts
+++ b/lib/sedp/sync-tag-types.ts
@@ -31,6 +31,7 @@ interface LinkCode {
START: number;
LENGTH: number;
IS_SEQ: boolean;
+ REG_EXPS?: string | null;
}
interface Attribute {
@@ -260,7 +261,7 @@ async function processAndSaveTagSubfields(
tagTypeCode: tagType.TYPE_ID,
attributesId: attributeId,
attributesDescription: attribute.DESC || attributeId,
- expression: attribute.REG_EXPS || null,
+ expression: linkCode.REG_EXPS || null,
delimiter: linkCode.DL_VAL || null,
sortOrder: linkCode.SEQ || 0,
updatedAt: new Date()
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 9689e855..c991aa42 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -1386,7 +1386,7 @@ export async function createTechSalesRfqAttachments(params: {
const [newAttachment] = await tx.insert(techSalesAttachments).values({
techSalesRfqId,
attachmentType,
- fileName: uniqueName,
+ fileName: saveResult.fileName,
originalFileName: file.name,
filePath: saveResult.publicPath,
fileSize: file.size,
diff --git a/lib/welding/service.ts b/lib/welding/service.ts
index feb6272b..424c4666 100644
--- a/lib/welding/service.ts
+++ b/lib/welding/service.ts
@@ -1,6 +1,6 @@
"use server";
-import { revalidateTag, unstable_noStore } from "next/cache";
+import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
@@ -11,6 +11,7 @@ import { OcrRow, ocrRows, users } from "@/db/schema";
import { countOcrRows, selectOcrRows } from "./repository";
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { z } from "zod";
@@ -166,6 +167,8 @@ export async function getOcrAllRows(): Promise<OcrRow[]> {
sessionId: ocrRows.sessionId,
rowIndex: ocrRows.rowIndex,
reportNo: ocrRows.reportNo,
+ fileName: ocrRows.fileName,
+ inspectionDate: ocrRows.inspectionDate,
no: ocrRows.no,
identificationNo: ocrRows.identificationNo,
tagNo: ocrRows.tagNo,
@@ -186,10 +189,50 @@ export async function getOcrAllRows(): Promise<OcrRow[]> {
.leftJoin(users, eq(ocrRows.userId, users.id))
.orderBy(desc(ocrRows.createdAt))
+ console.log(allRows.length)
+
return allRows
} catch (error) {
console.error("Error fetching all OCR rows:", error)
throw new Error("Failed to fetch all OCR data")
}
+}
+
+
+const removeOcrRowsSchema = z.object({
+ ids: z.array(z.string().uuid()),
+})
+
+export async function removeOcrRows(
+ input: z.infer<typeof removeOcrRowsSchema>
+) {
+ try {
+ const { ids } = removeOcrRowsSchema.parse(input)
+
+ if (ids.length === 0) {
+ return {
+ data: null,
+ error: "삭제할 데이터를 선택해주세요.",
+ }
+ }
+
+ // OCR 행들을 삭제
+ await db
+ .delete(ocrRows)
+ .where(inArray(ocrRows.id, ids))
+
+ revalidatePath("/partners/ocr")
+
+ return {
+ data: null,
+ error: null,
+ }
+ } catch (error) {
+ console.error("OCR 행 삭제 중 오류 발생:", error)
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.",
+ }
+ }
} \ No newline at end of file
diff --git a/lib/welding/table/delete-ocr-rows-dialog.tsx b/lib/welding/table/delete-ocr-rows-dialog.tsx
new file mode 100644
index 00000000..8e67eea3
--- /dev/null
+++ b/lib/welding/table/delete-ocr-rows-dialog.tsx
@@ -0,0 +1,151 @@
+"use client"
+
+import * as React from "react"
+import { type OcrRow } from "@/db/schema"
+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 { removeOcrRows } from "../service"
+
+interface DeleteOcrRowsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ ocrRows: Row<OcrRow>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteOcrRowsDialog({
+ ocrRows,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteOcrRowsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeOcrRows({
+ ids: ocrRows.map((row) => row.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success(
+ `${ocrRows.length}개의 OCR 데이터가 성공적으로 삭제되었습니다.`
+ )
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({ocrRows.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{ocrRows.length}</span>개의 OCR 데이터가
+ 서버에서 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({ocrRows.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{ocrRows.length}</span>개의 OCR 데이터가
+ 서버에서 영구적으로 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택된 행 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/welding/table/exporft-ocr-data.ts b/lib/welding/table/exporft-ocr-data.ts
index 76856808..729b1beb 100644
--- a/lib/welding/table/exporft-ocr-data.ts
+++ b/lib/welding/table/exporft-ocr-data.ts
@@ -19,6 +19,8 @@ export async function exportOcrDataToExcel(
// 컬럼 정의 (OCR 데이터에 맞게 설정)
const columns = [
+ { key: "fileName", header: "file Name", width: 20 },
+ { key: "inspectionDate", header: "inspection Date", width: 20 },
{ key: "reportNo", header: "Report No", width: 15 },
{ key: "no", header: "No", width: 10 },
{ key: "identificationNo", header: "Identification No", width: 20 },
@@ -52,6 +54,8 @@ export async function exportOcrDataToExcel(
reportNo: row.reportNo || "",
no: row.no || "",
identificationNo: row.identificationNo || "",
+ inspectionDate: row.inspectionDate ? new Date(row.inspectionDate).toLocaleDateString() : "",
+ fileName: row.fileName || "",
tagNo: row.tagNo || "",
jointNo: row.jointNo || "",
jointType: row.jointType || "",
diff --git a/lib/welding/table/ocr-table-columns.tsx b/lib/welding/table/ocr-table-columns.tsx
index d4ca9f5f..6413010d 100644
--- a/lib/welding/table/ocr-table-columns.tsx
+++ b/lib/welding/table/ocr-table-columns.tsx
@@ -346,14 +346,14 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow>
<DropdownMenuSeparator />
- <DropdownMenuItem
+ {/* <DropdownMenuItem
onClick={() => {
setRowAction({ type: "delete", row })
}}
className="text-destructive focus:text-destructive"
>
Delete
- </DropdownMenuItem>
+ </DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
),
diff --git a/lib/welding/table/ocr-table-toolbar-actions.tsx b/lib/welding/table/ocr-table-toolbar-actions.tsx
index 03d8cab0..a6a38adc 100644
--- a/lib/welding/table/ocr-table-toolbar-actions.tsx
+++ b/lib/welding/table/ocr-table-toolbar-actions.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, RefreshCcw, Upload, FileText, Loader2, ChevronDown, X, Play, Pause, RotateCcw } from "lucide-react"
+import { Download, RefreshCcw, Upload, FileText, Loader2, ChevronDown, X, Play, Pause, RotateCcw, Trash } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -48,6 +48,7 @@ import {
} from "@/components/ui/file-list"
import { getOcrAllRows } from "../service"
import { exportOcrDataToExcel } from "./exporft-ocr-data"
+import { DeleteOcrRowsDialog } from "./delete-ocr-rows-dialog"
interface OcrTableToolbarActionsProps {
table: Table<OcrRow>
@@ -96,6 +97,9 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
const [isPaused, setIsPaused] = React.useState(false)
const batchControllerRef = React.useRef<AbortController | null>(null)
+ // 선택된 행들
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
// 단일 파일 업로드 다이얼로그 닫기 핸들러
const handleDialogOpenChange = (open: boolean) => {
if (!open) {
@@ -522,6 +526,14 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
}
}
+ // 삭제 후 콜백 - 테이블 새로고침
+ const handleDeleteSuccess = () => {
+ // 선택 해제
+ table.resetRowSelection()
+ // 페이지 새로고침
+ window.location.reload()
+ }
+
const getStatusBadgeVariant = (status: FileUploadItem['status']) => {
switch (status) {
case 'pending': return 'secondary'
@@ -554,6 +566,14 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
return (
<div className="flex items-center gap-2">
+ {/* 선택된 행이 있을 때만 삭제 버튼 표시 */}
+ {selectedRows.length > 0 && (
+ <DeleteOcrRowsDialog
+ ocrRows={selectedRows}
+ onSuccess={handleDeleteSuccess}
+ />
+ )}
+
{/* 단일 파일 OCR 업로드 다이얼로그 */}
<Dialog open={isUploadDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
diff --git a/lib/welding/table/ocr-table.tsx b/lib/welding/table/ocr-table.tsx
index e14c53d1..443f5a6b 100644
--- a/lib/welding/table/ocr-table.tsx
+++ b/lib/welding/table/ocr-table.tsx
@@ -70,6 +70,18 @@ export function OcrTable({ promises }: ItemsTableProps) {
// group: "Basic Info",
},
{
+ id: "fileName",
+ label: "file Name",
+ type: "text",
+ // group: "Basic Info",
+ },
+ {
+ id: "reportNo",
+ label: "report No",
+ type: "text",
+ // group: "Basic Info",
+ },
+ {
id: "no",
label: "No",
type: "text",