diff options
Diffstat (limited to 'lib')
45 files changed, 4795 insertions, 1397 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 87a861e1..014f32ab 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -49,8 +49,6 @@ export async function addTemplate( if (templateData instanceof FormData) {
const templateName = templateData.get("templateName") as string;
// 문자열을 숫자로 변환 (FormData의 get은 항상 string|File을 반환)
- const validityPeriodStr = templateData.get("validityPeriod") as string;
- const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12; // 기본값 12개월
const status = templateData.get("status") as "ACTIVE" | "INACTIVE";
const file = templateData.get("file") as File;
@@ -63,12 +61,6 @@ export async function addTemplate( return { success: false, error: "파일은 필수입니다." };
}
- if (isNaN(validityPeriod) || validityPeriod < 1 || validityPeriod > 120) {
- return {
- success: false,
- error: "유효기간은 1~120개월 사이의 유효한 값이어야 합니다."
- };
- }
const saveResult = await saveFile({file, directory:"basicContract/template" });
if (!saveResult.success) {
@@ -79,7 +71,6 @@ export async function addTemplate( const formattedData = {
templateName,
status,
- validityPeriod, // 숫자로 변환된 유효기간
fileName: file.name,
filePath: saveResult.publicPath!
};
@@ -196,26 +187,31 @@ export async function getBasicContractTemplates( // 템플릿 생성 (서버 액션)
-export async function createBasicContractTemplate(
- input: CreateBasicContractTemplateSchema
-) {
+export async function createBasicContractTemplate(input: CreateBasicContractTemplateSchema) {
unstable_noStore();
+
try {
const newTemplate = await db.transaction(async (tx) => {
- const [newTemplate] = await insertBasicContractTemplate(tx, {
+ const [row] = await insertBasicContractTemplate(tx, {
templateName: input.templateName,
- validityPeriod: input.validityPeriod,
+ revision: 1,
+ legalReviewRequired: input.legalReviewRequired,
+ shipBuildingApplicable: input.shipBuildingApplicable,
+ windApplicable: input.windApplicable,
+ pcApplicable: input.pcApplicable,
+ nbApplicable: input.nbApplicable,
+ rcApplicable: input.rcApplicable,
+ gyApplicable: input.gyApplicable,
+ sysApplicable: input.sysApplicable,
+ infraApplicable: input.infraApplicable,
status: input.status,
fileName: input.fileName,
filePath: input.filePath,
+ // 필요하면 createdAt/updatedAt 등도 여기서
});
- return newTemplate;
+ return row;
});
- // 캐시 무효화
- revalidateTag("basic-contract-templates");
- revalidateTag("template-status-counts");
-
return { data: newTemplate, error: null };
} catch (error) {
return { data: null, error: getErrorMessage(error) };
@@ -350,6 +346,23 @@ interface UpdateTemplateParams { formData: FormData;
}
+const SCOPE_KEYS = [
+ "shipBuildingApplicable",
+ "windApplicable",
+ "pcApplicable",
+ "nbApplicable",
+ "rcApplicable",
+ "gyApplicable",
+ "sysApplicable",
+ "infraApplicable",
+] as const;
+
+function getBool(fd: FormData, key: string, defaultValue = false) {
+ const v = fd.get(key);
+ if (v === null) return defaultValue;
+ return v === "true";
+}
+
export async function updateTemplate({
id,
formData
@@ -357,51 +370,76 @@ export async function updateTemplate({ unstable_noStore();
try {
- const templateName = formData.get("templateName") as string;
- const validityPeriodStr = formData.get("validityPeriod") as string;
- const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12;
- const status = formData.get("status") as "ACTIVE" | "INACTIVE";
- const file = formData.get("file") as File | null;
-
+ // 필수값
+ const templateName = formData.get("templateName") as string | null;
if (!templateName) {
return { error: "템플릿 이름은 필수입니다." };
}
- // 기본 업데이트 데이터
- const updateData: Record<string, any> = {
- templateName,
- status,
- validityPeriod,
- updatedAt: new Date(),
- };
+ // 선택/추가 필드 파싱
+ const revisionStr = formData.get("revision")?.toString() ?? "1";
+ const revision = Number(revisionStr) || 1;
+
+ const legalReviewRequired = getBool(formData, "legalReviewRequired", false);
+
+ // status는 프런트에서 ACTIVE만 넣고 있으나, 없으면 기존값 유지 or 기본값 설정
+ const status = (formData.get("status") as "ACTIVE" | "INACTIVE" | null) ?? "ACTIVE";
+ // validityPeriod가 이제 필요없다면 제거하시고, 사용한다면 파싱 그대로
+ const validityPeriodStr = formData.get("validityPeriod")?.toString();
+ const validityPeriod = validityPeriodStr ? Number(validityPeriodStr) : undefined;
+
+ // Scope booleans
+ const scopeData: Record<string, boolean> = {};
+ for (const key of SCOPE_KEYS) {
+ scopeData[key] = getBool(formData, key, false);
+ }
+
+ // 파일 처리
+ const file = formData.get("file") as File | null;
+ let fileName: string | undefined = undefined;
+ let filePath: string | undefined = undefined;
- // 파일이 있는 경우 처리
if (file) {
- const saveResult = await saveFile({file,directory:"basicContract/template"});
+ // 1) 새 파일 저장
+ const saveResult = await saveFile({ file, directory: "basicContract/template" });
if (!saveResult.success) {
return { success: false, error: saveResult.error };
}
+ fileName = file.name;
+ filePath = saveResult.publicPath;
- // 기존 파일 정보 가져오기
+ // 2) 기존 파일 삭제
const existingTemplate = await db.query.basicContractTemplates.findFirst({
- where: eq(basicContractTemplates.id, id)
+ where: eq(basicContractTemplates.id, id),
});
- // 기존 파일이 있다면 삭제
if (existingTemplate?.filePath) {
-
const deleted = await deleteFile(existingTemplate.filePath);
if (deleted) {
- console.log(`✅ 파일 삭제됨: ${existingTemplate.filePath}`);
+ console.log(`✅ 기존 파일 삭제됨: ${existingTemplate.filePath}`);
} else {
- console.log(`⚠️ 파일 삭제 실패: ${existingTemplate.filePath}`);
+ console.log(`⚠️ 기존 파일 삭제 실패: ${existingTemplate.filePath}`);
}
}
+ }
- // 업데이트 데이터에 파일 정보 추가
- updateData.fileName = file.name;
- updateData.filePath = saveResult.publicPath;
+ // 업데이트할 데이터 구성
+ const updateData: Record<string, any> = {
+ templateName,
+ revision,
+ legalReviewRequired,
+ status,
+ updatedAt: new Date(),
+ ...scopeData,
+ };
+
+ if (validityPeriod !== undefined) {
+ updateData.validityPeriod = validityPeriod;
+ }
+ if (fileName && filePath) {
+ updateData.fileName = fileName;
+ updateData.filePath = filePath;
}
// DB 업데이트
@@ -412,7 +450,7 @@ export async function updateTemplate({ .where(eq(basicContractTemplates.id, id));
});
- // 캐시 무효화 (다양한 방법 시도)
+ // 캐시 무효화
revalidateTag("basic-contract-templates");
revalidateTag("template-status-counts");
revalidateTag("templates");
@@ -423,7 +461,7 @@ export async function updateTemplate({ return {
error: error instanceof Error
? error.message
- : "템플릿 업데이트 중 오류가 발생했습니다."
+ : "템플릿 업데이트 중 오류가 발생했습니다.",
};
}
}
@@ -987,4 +1025,106 @@ export async function saveTemplateFile(templateId: number, formData: FormData) { export async function refreshTemplatePage(templateId: string) {
revalidatePath(`/evcp/basic-contract-template/${templateId}`);
revalidateTag("basic-contract-templates");
+}
+
+// 새 리비전 생성 함수
+export async function createBasicContractTemplateRevision(input: CreateRevisionSchema) {
+ unstable_noStore();
+
+ try {
+ // 기본 템플릿 존재 확인
+ const baseTemplate = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.id, input.baseTemplateId))
+ .limit(1);
+
+ if (baseTemplate.length === 0) {
+ return { data: null, error: "기본 템플릿을 찾을 수 없습니다." };
+ }
+
+ // 같은 템플릿 이름에 해당 리비전이 이미 존재하는지 확인
+ const existingRevision = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ eq(basicContractTemplates.templateName, input.templateName),
+ eq(basicContractTemplates.revision, input.revision)
+ )
+ )
+ .limit(1);
+
+ if (existingRevision.length > 0) {
+ return {
+ data: null,
+ error: `${input.templateName} v${input.revision} 리비전이 이미 존재합니다.`
+ };
+ }
+
+ // 새 리비전이 기존 리비전들보다 큰 번호인지 확인
+ const maxRevision = await db
+ .select({ maxRev: basicContractTemplates.revision })
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.templateName, input.templateName))
+ .orderBy(desc(basicContractTemplates.revision))
+ .limit(1);
+
+ if (maxRevision.length > 0 && input.revision <= maxRevision[0].maxRev) {
+ return {
+ data: null,
+ error: `새 리비전 번호는 현재 최대 리비전(v${maxRevision[0].maxRev})보다 커야 합니다.`
+ };
+ }
+
+ const newRevision = await db.transaction(async (tx) => {
+ const [row] = await insertBasicContractTemplate(tx, {
+ templateName: input.templateName,
+ revision: input.revision,
+ legalReviewRequired: input.legalReviewRequired,
+ shipBuildingApplicable: input.shipBuildingApplicable,
+ windApplicable: input.windApplicable,
+ pcApplicable: input.pcApplicable,
+ nbApplicable: input.nbApplicable,
+ rcApplicable: input.rcApplicable,
+ gyApplicable: input.gyApplicable,
+ sysApplicable: input.sysApplicable,
+ infraApplicable: input.infraApplicable,
+ status: "ACTIVE",
+ fileName: input.fileName,
+ filePath: input.filePath,
+ validityPeriod: null,
+ });
+ return row;
+ });
+
+ return { data: newRevision, error: null };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+
+
+
+// 1) 전체 basicContractTemplates 조회
+export async function getALLBasicContractTemplates() {
+ return db
+ .select()
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.status,"ACTIVE"))
+ .orderBy(desc(basicContractTemplates.createdAt));
+}
+
+// 2) 등록된 templateName만 중복 없이 가져오기
+export async function getExistingTemplateNames(): Promise<string[]> {
+ const rows = await db
+ .select({
+ templateName: basicContractTemplates.templateName,
+ })
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.status,"ACTIVE"))
+ .groupBy(basicContractTemplates.templateName);
+
+ return rows.map((r) => r.templateName);
}
\ No newline at end of file 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 9b036445..fd1bd333 100644 --- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx +++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx @@ -40,10 +40,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Separator } from "@/components/ui/separator";
import { useRouter } from "next/navigation";
import { BUSINESS_UNITS } from "@/config/basicContractColumnsConfig";
+import { getExistingTemplateNames } from "../service";
-// 템플릿 이름 옵션 정의
+// ✅ 서버 액션 import
+
+// 전체 템플릿 후보
const TEMPLATE_NAME_OPTIONS = [
- "준법서약",
+ "준법서약 (한글)",
+ "준법서약 (영문)",
"기술자료 요구서",
"비밀유지 계약서",
"표준하도급기본 계약서",
@@ -56,14 +60,11 @@ const TEMPLATE_NAME_OPTIONS = [ "직납자재 하도급대급등 연동제 의향서"
] as const;
-// 업데이트된 계약서 템플릿 스키마 정의 (워드파일만 허용)
const templateFormSchema = z.object({
templateName: z.enum(TEMPLATE_NAME_OPTIONS, {
required_error: "템플릿 이름을 선택해주세요.",
}),
- revision: z.coerce.number().int().min(1).default(1),
legalReviewRequired: z.boolean().default(false),
-
// 적용 범위
shipBuildingApplicable: z.boolean().default(false),
windApplicable: z.boolean().default(false),
@@ -73,31 +74,42 @@ const templateFormSchema = z.object({ 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, {
- message: "파일 크기는 100MB 이하여야 합니다.",
- })
- .refine(
- (file) =>
- file.type === 'application/msword' ||
- file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
- { message: "워드 파일(.doc, .docx)만 업로드 가능합니다." }
- ),
- status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"),
-}).refine((data) => {
- // 적어도 하나의 적용 범위는 선택되어야 함
+ file: z.instanceof(File).optional(),
+})
+.refine((data) => {
+ if (data.templateName !== "General GTC" && !data.file) return false;
+ return true;
+}, {
+ message: "파일을 업로드해주세요.",
+ path: ["file"],
+})
+.refine((data) => {
+ if (data.file && data.file.size > 100 * 1024 * 1024) return false;
+ return true;
+}, {
+ message: "파일 크기는 100MB 이하여야 합니다.",
+ path: ["file"],
+})
+.refine((data) => {
+ if (data.file) {
+ const isValidType = data.file.type === 'application/msword' ||
+ data.file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+ return isValidType;
+ }
+ return true;
+}, {
+ message: "워드 파일(.doc, .docx)만 업로드 가능합니다.",
+ path: ["file"],
+})
+.refine((data) => {
const scopeFields = [
'shipBuildingApplicable', 'windApplicable', 'pcApplicable', 'nbApplicable',
'rcApplicable', 'gyApplicable', 'sysApplicable', 'infraApplicable'
];
-
- const hasAnyScope = scopeFields.some(field => data[field as keyof typeof data] === true);
- return hasAnyScope;
+ return scopeFields.some(field => data[field as keyof typeof data] === true);
}, {
message: "적어도 하나의 적용 범위를 선택해야 합니다.",
- path: ["shipBuildingApplicable"], // 에러를 첫 번째 체크박스에 표시
+ path: ["shipBuildingApplicable"],
});
type TemplateFormValues = z.infer<typeof templateFormSchema>;
@@ -108,12 +120,12 @@ export function AddTemplateDialog() { const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState(0);
const [showProgress, setShowProgress] = React.useState(false);
+ const [availableTemplateNames, setAvailableTemplateNames] = React.useState<typeof TEMPLATE_NAME_OPTIONS[number][]>(TEMPLATE_NAME_OPTIONS);
const router = useRouter();
- // 기본값 설정 (templateCode 제거)
+ // 기본값
const defaultValues: Partial<TemplateFormValues> = {
templateName: undefined,
- revision: 1,
legalReviewRequired: false,
shipBuildingApplicable: false,
windApplicable: false,
@@ -123,17 +135,33 @@ export function AddTemplateDialog() { gyApplicable: false,
sysApplicable: false,
infraApplicable: false,
- status: "ACTIVE",
};
- // 폼 초기화
const form = useForm<TemplateFormValues>({
resolver: zodResolver(templateFormSchema),
defaultValues,
mode: "onChange",
});
- // 파일 선택 핸들러
+ // 🔸 마운트 시 이미 등록된 templateName 목록 가져와서 필터링
+ React.useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const usedNames = await getExistingTemplateNames();
+ if (cancelled) return;
+
+ // 이미 있는 이름 제외
+ const filtered = TEMPLATE_NAME_OPTIONS.filter(name => !usedNames.includes(name));
+ setAvailableTemplateNames(filtered);
+ } catch (err) {
+ console.error("Failed to fetch existing template names", err);
+ // 실패 시 전체 옵션 보여주거나, 오류 알려주기
+ }
+ })();
+ return () => { cancelled = true; };
+ }, []);
+
const handleFileChange = (files: File[]) => {
if (files.length > 0) {
const file = files[0];
@@ -142,88 +170,71 @@ 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;
- // 파일을 청크로 분할하여 업로드하는 함수
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;
+
+ 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;
}
}
};
- // 폼 제출 핸들러 (templateCode 제거)
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("파일 업로드에 실패했습니다.");
+ let uploadResult = null;
+
+ if (formData.file) {
+ const fileId = uuidv4();
+ uploadResult = await uploadFileInChunks(formData.file, fileId);
+
+ if (!uploadResult?.success) {
+ throw new Error("파일 업로드에 실패했습니다.");
+ }
}
-
- // 메타데이터 저장 (templateCode 제거됨)
+
const saveResponse = await fetch('/api/upload/basicContract/complete', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
templateName: formData.templateName,
- revision: formData.revision,
+ revision: 1,
legalReviewRequired: formData.legalReviewRequired,
shipBuildingApplicable: formData.shipBuildingApplicable,
windApplicable: formData.windApplicable,
@@ -233,35 +244,32 @@ export function AddTemplateDialog() { gyApplicable: formData.gyApplicable,
sysApplicable: formData.sysApplicable,
infraApplicable: formData.infraApplicable,
- status: formData.status,
- fileName: uploadResult.fileName,
- filePath: uploadResult.filePath,
+ status: "ACTIVE",
+ fileName: uploadResult?.fileName || `${formData.templateName}_v1.docx`,
+ filePath: uploadResult?.filePath || "",
}),
next: { tags: ["basic-contract-templates"] },
});
-
+
const saveResult = await saveResponse.json();
-
if (!saveResult.success) {
- throw new Error("템플릿 정보 저장에 실패했습니다.");
+ throw new Error(saveResult.error || "템플릿 정보 저장에 실패했습니다.");
}
-
+
toast.success('템플릿이 성공적으로 추가되었습니다.');
form.reset();
setSelectedFile(null);
setOpen(false);
setShowProgress(false);
-
router.refresh();
} catch (error) {
console.error("Submit error:", error);
- toast.error("템플릿 추가 중 오류가 발생했습니다.");
+ toast.error(error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
}
- // 모달이 닫힐 때 폼 초기화
React.useEffect(() => {
if (!open) {
form.reset();
@@ -278,11 +286,17 @@ export function AddTemplateDialog() { setOpen(nextOpen);
}
- // 현재 선택된 적용 범위 수
- const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
+ const selectedScopesCount = BUSINESS_UNITS.filter(unit =>
form.watch(unit.key as keyof TemplateFormValues)
).length;
+ const templateNameIsRequired = form.watch("templateName") !== "General GTC";
+
+ const isSubmitDisabled = isLoading ||
+ !form.watch("templateName") ||
+ (templateNameIsRequired && !form.watch("file")) ||
+ !BUSINESS_UNITS.some(unit => form.watch(unit.key as keyof TemplateFormValues));
+
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
@@ -291,16 +305,14 @@ export function AddTemplateDialog() { </Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] h-[90vh] flex flex-col p-0">
- {/* 고정된 헤더 */}
<DialogHeader className="p-6 pb-4 border-b">
<DialogTitle>새 기본계약서 템플릿 추가</DialogTitle>
<DialogDescription>
- 템플릿 정보를 입력하고 계약서 파일을 업로드하세요.
+ 템플릿 정보를 입력하고 계약서 파일을 업로드하세요. (리비전은 자동으로 1로 설정됩니다)
<span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
</DialogDescription>
</DialogHeader>
- {/* 스크롤 가능한 컨텐츠 영역 */}
<div className="flex-1 overflow-y-auto px-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-4">
@@ -310,7 +322,7 @@ export function AddTemplateDialog() { <CardTitle className="text-lg">기본 정보</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="templateName"
@@ -319,14 +331,18 @@ export function AddTemplateDialog() { <FormLabel>
템플릿 이름 <span className="text-red-500">*</span>
</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <Select onValueChange={field.onChange} value={field.value}>
<FormControl>
- <SelectTrigger>
- <SelectValue placeholder="템플릿 이름을 선택하세요" />
+ <SelectTrigger disabled={availableTemplateNames.length === 0}>
+ <SelectValue placeholder={
+ availableTemplateNames.length === 0
+ ? "사용 가능한 템플릿이 없습니다"
+ : "템플릿 이름을 선택하세요"
+ } />
</SelectTrigger>
</FormControl>
<SelectContent>
- {TEMPLATE_NAME_OPTIONS.map((option) => (
+ {availableTemplateNames.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
@@ -334,33 +350,7 @@ export function AddTemplateDialog() { </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)
- <br />
- <span className="text-xs text-muted-foreground">
- 동일한 템플릿 이름의 리비전은 중복될 수 없습니다.
- </span>
+ 이미 등록되지 않은 템플릿만 표시됩니다. (리비전 1로 생성)
</FormDescription>
<FormMessage />
</FormItem>
@@ -412,9 +402,9 @@ export function AddTemplateDialog() { 전체 선택
</label>
</div>
-
+
<Separator />
-
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{BUSINESS_UNITS.map((unit) => (
<FormField
@@ -439,7 +429,7 @@ export function AddTemplateDialog() { />
))}
</div>
-
+
{form.formState.errors.shipBuildingApplicable && (
<p className="text-sm text-destructive">
{form.formState.errors.shipBuildingApplicable.message}
@@ -452,6 +442,11 @@ export function AddTemplateDialog() { <Card>
<CardHeader>
<CardTitle className="text-lg">파일 업로드</CardTitle>
+ <CardDescription>
+ {form.watch("templateName") === "General GTC"
+ ? "General GTC는 파일 업로드가 선택사항입니다"
+ : "템플릿 파일을 업로드하세요"}
+ </CardDescription>
</CardHeader>
<CardContent>
<FormField
@@ -460,7 +455,13 @@ export function AddTemplateDialog() { render={() => (
<FormItem>
<FormLabel>
- 계약서 파일 <span className="text-red-500">*</span>
+ 템플릿 파일
+ {form.watch("templateName") !== "General GTC" && (
+ <span className="text-red-500"> *</span>
+ )}
+ {form.watch("templateName") === "General GTC" && (
+ <span className="text-muted-foreground"> (선택사항)</span>
+ )}
</FormLabel>
<FormControl>
<Dropzone
@@ -478,7 +479,9 @@ export function AddTemplateDialog() { <DropzoneDescription>
{selectedFile
? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
- : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"}
+ : form.watch("templateName") === "General GTC"
+ ? "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (선택사항, 최대 100MB)"
+ : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"}
</DropzoneDescription>
<DropzoneInput />
</DropzoneZone>
@@ -488,7 +491,7 @@ export function AddTemplateDialog() { </FormItem>
)}
/>
-
+
{showProgress && (
<div className="space-y-2 mt-4">
<div className="flex justify-between text-sm">
@@ -504,7 +507,6 @@ export function AddTemplateDialog() { </Form>
</div>
- {/* 고정된 푸터 */}
<DialogFooter className="p-6 pt-4 border-t">
<Button
type="button"
@@ -517,7 +519,7 @@ export function AddTemplateDialog() { <Button
type="button"
onClick={form.handleSubmit(onSubmit)}
- disabled={isLoading || !form.formState.isValid}
+ disabled={isSubmitDisabled}
>
{isLoading ? "처리 중..." : "추가"}
</Button>
@@ -525,4 +527,4 @@ export function AddTemplateDialog() { </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 index b7c2fa08..5783ca27 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, CheckCircle, XCircle, Eye } from "lucide-react"
+import { Download, Ellipsis, Paperclip, CheckCircle, XCircle, Eye, Copy, GitBranch } from "lucide-react"
import { toast } from "sonner"
import { getErrorMessage } from "@/lib/handle-error"
@@ -119,7 +119,13 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef const template = row.original;
const handleViewDetails = () => {
- router.push(`/evcp/basic-contract-template/${template.id}`);
+ // templateName이 "General GTC"인 경우 특별한 라우팅
+ if (template.templateName === "General GTC") {
+ router.push(`/evcp/basic-contract-template/gtc`);
+ } else {
+ // 일반적인 경우는 기존과 동일
+ router.push(`/evcp/basic-contract-template/${template.id}`);
+ }
};
return (
@@ -133,25 +139,34 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef <Ellipsis className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={handleViewDetails}>
<Eye className="mr-2 h-4 w-4" />
View Details
</DropdownMenuItem>
-
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "createRevision" })}
+ >
+ <GitBranch className="mr-2 h-4 w-4" />
+ 리비전 생성
+ </DropdownMenuItem>
+
<DropdownMenuSeparator />
-
+
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "update" })}
>
- Edit
+ 수정하기
</DropdownMenuItem>
{template.status === 'ACTIVE' && (
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "dispose" })}
>
- Dispose
+ 폐기하기
</DropdownMenuItem>
)}
@@ -159,7 +174,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef <DropdownMenuItem
onSelect={() => setRowAction({ row, type: "restore" })}
>
- Restore
+ 복구하기
</DropdownMenuItem>
)}
@@ -204,7 +219,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약문서명" />,
cell: ({ row }) => {
const template = row.original;
-
+
const handleClick = () => {
router.push(`/evcp/basic-contract-template/${template.id}`);
};
@@ -230,10 +245,14 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef cell: ({ row }) => {
const template = row.original;
return (
- <span className="text-xs text-muted-foreground">v{template.revision}</span>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="text-xs">
+ v{template.revision}
+ </Badge>
+ </div>
);
},
- size: 60,
+ size: 80,
enableResizing: true,
},
{
diff --git a/lib/basic-contract/template/basic-contract-template.tsx b/lib/basic-contract/template/basic-contract-template.tsx index 4fc70af4..470bc925 100644 --- a/lib/basic-contract/template/basic-contract-template.tsx +++ b/lib/basic-contract/template/basic-contract-template.tsx @@ -13,6 +13,7 @@ import { getBasicContractTemplates} from "../service"; import { getColumns } from "./basic-contract-template-columns"; import { DeleteTemplatesDialog } from "./delete-basicContract-dialog"; import { UpdateTemplateSheet } from "./update-basicContract-sheet"; +import { CreateRevisionDialog } from "./create-revision-dialog"; import { TemplateTableToolbarActions } from "./basicContract-table-toolbar-actions"; import { BasicContractTemplate } from "@/db/schema"; @@ -30,7 +31,7 @@ export function BasicContractTemplateTable({ promises }: BasicTemplateTableProps React.useState<DataTableRowAction<BasicContractTemplate> | null>(null) const [{ data, pageCount }] = React.use(promises) - + // 컬럼 설정 - router를 전달 const columns = React.useMemo( () => getColumns({ setRowAction, router }), @@ -90,6 +91,16 @@ export function BasicContractTemplateTable({ promises }: BasicTemplateTableProps onOpenChange={() => setRowAction(null)} template={rowAction?.row.original ?? null} /> + + <CreateRevisionDialog + open={rowAction?.type === "createRevision"} + onOpenChange={() => setRowAction(null)} + baseTemplate={rowAction?.row.original ?? null} + onSuccess={() => { + setRowAction(null); + router.refresh(); + }} + /> </> ); }
\ No newline at end of file diff --git a/lib/basic-contract/template/create-revision-dialog.tsx b/lib/basic-contract/template/create-revision-dialog.tsx new file mode 100644 index 00000000..262df6ba --- /dev/null +++ b/lib/basic-contract/template/create-revision-dialog.tsx @@ -0,0 +1,564 @@ +"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 { Copy, FileText, Loader } from "lucide-react"; +import { Dialog, 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 { Checkbox } from "@/components/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; +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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { useRouter } from "next/navigation"; +import { BUSINESS_UNITS } from "@/config/basicContractColumnsConfig"; +import { BasicContractTemplate } from "@/db/schema"; + +// 템플릿 이름 옵션 정의 +const TEMPLATE_NAME_OPTIONS = [ + "준법서약 (한글)", + "준법서약 (영문)", + "기술자료 요구서", + "비밀유지 계약서", + "표준하도급기본 계약서", + "General GTC", + "안전보건관리 약정서", + "동반성장", + "윤리규범 준수 서약서", + "기술자료 동의서", + "내국신용장 미개설 합의서", + "직납자재 하도급대급등 연동제 의향서" +] as const; + +// 리비전 생성 스키마 정의 +const createRevisionSchema = z.object({ + revision: z.coerce.number().int().min(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, { + message: "파일 크기는 100MB 이하여야 합니다.", + }) + .refine( + (file) => + file.type === 'application/msword' || + file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + { message: "워드 파일(.doc, .docx)만 업로드 가능합니다." } + ), +}).refine((data) => { + // 적어도 하나의 적용 범위는 선택되어야 함 + const scopeFields = [ + 'shipBuildingApplicable', 'windApplicable', 'pcApplicable', 'nbApplicable', + 'rcApplicable', 'gyApplicable', 'sysApplicable', 'infraApplicable' + ]; + + const hasAnyScope = scopeFields.some(field => data[field as keyof typeof data] === true); + return hasAnyScope; +}, { + message: "적어도 하나의 적용 범위를 선택해야 합니다.", + path: ["shipBuildingApplicable"], +}); + +type CreateRevisionFormValues = z.infer<typeof createRevisionSchema>; + +interface CreateRevisionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + baseTemplate: BasicContractTemplate | null; + onSuccess?: () => void; +} + +export function CreateRevisionDialog({ + open, + onOpenChange, + baseTemplate, + onSuccess +}: CreateRevisionDialogProps) { + 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 [suggestedRevision, setSuggestedRevision] = React.useState<number>(1); + const router = useRouter(); + + // 기본 템플릿의 다음 리비전 번호 계산 + React.useEffect(() => { + if (baseTemplate) { + setSuggestedRevision(baseTemplate.revision + 1); + } + }, [baseTemplate]); + + // 기본값 설정 (기존 템플릿의 설정을 상속) + const defaultValues: Partial<CreateRevisionFormValues> = React.useMemo(() => { + if (!baseTemplate) return {}; + + return { + revision: suggestedRevision, + legalReviewRequired: baseTemplate.legalReviewRequired, + shipBuildingApplicable: baseTemplate.shipBuildingApplicable, + windApplicable: baseTemplate.windApplicable, + pcApplicable: baseTemplate.pcApplicable, + nbApplicable: baseTemplate.nbApplicable, + rcApplicable: baseTemplate.rcApplicable, + gyApplicable: baseTemplate.gyApplicable, + sysApplicable: baseTemplate.sysApplicable, + infraApplicable: baseTemplate.infraApplicable, + }; + }, [baseTemplate, suggestedRevision]); + + // 폼 초기화 + const form = useForm<CreateRevisionFormValues>({ + resolver: zodResolver(createRevisionSchema), + defaultValues, + mode: "onChange", + }); + + // baseTemplate이 변경될 때 폼 값 재설정 + React.useEffect(() => { + if (baseTemplate && defaultValues) { + form.reset(defaultValues); + } + }, [baseTemplate, defaultValues, form]); + + // 파일 선택 핸들러 + const handleFileChange = (files: File[]) => { + if (files.length > 0) { + const file = files[0]; + setSelectedFile(file); + form.setValue("file", file); + } + }; + + // 모든 적용 범위 선택/해제 + const handleSelectAllScopes = (checked: boolean) => { + BUSINESS_UNITS.forEach(unit => { + form.setValue(unit.key as keyof CreateRevisionFormValues, checked); + }); + }; + + // 이전 설정 복사 + const handleCopyPreviousSettings = () => { + if (!baseTemplate) return; + + BUSINESS_UNITS.forEach(unit => { + const value = baseTemplate[unit.key as keyof BasicContractTemplate] as boolean; + form.setValue(unit.key as keyof CreateRevisionFormValues, value); + }); + + form.setValue("legalReviewRequired", baseTemplate.legalReviewRequired); + toast.success("이전 설정이 복사되었습니다."); + }; + + // 청크 크기 설정 (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: CreateRevisionFormValues) { + if (!baseTemplate) return; + + 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("파일 업로드에 실패했습니다."); + } + + // 새 리비전 생성 API 호출 + const createResponse = await fetch('/api/basicContract/create-revision', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + baseTemplateId: baseTemplate.id, + templateName: baseTemplate.templateName, + 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, + fileName: uploadResult.fileName, + filePath: uploadResult.filePath, + }), + next: { tags: ["basic-contract-templates"] }, + }); + + const createResult = await createResponse.json(); + + if (!createResult.success) { + throw new Error(createResult.error || "리비전 생성에 실패했습니다."); + } + + toast.success(`${baseTemplate.templateName} v${formData.revision} 리비전이 성공적으로 생성되었습니다.`); + form.reset(); + setSelectedFile(null); + onOpenChange(false); + setShowProgress(false); + onSuccess?.(); + 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]); + + // 현재 선택된 적용 범위 수 + const selectedScopesCount = BUSINESS_UNITS.filter(unit => + form.watch(unit.key as keyof CreateRevisionFormValues) + ).length; + + if (!baseTemplate) return null; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px] h-[90vh] flex flex-col p-0"> + {/* 고정된 헤더 */} + <DialogHeader className="p-6 pb-4 border-b"> + <DialogTitle className="flex items-center gap-2"> + <Copy className="h-5 w-5" /> + 새 리비전 생성 + </DialogTitle> + <DialogDescription> + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4" /> + <span className="font-medium">{baseTemplate.templateName}</span> + <Badge variant="outline">현재 v{baseTemplate.revision}</Badge> + <span>→</span> + <Badge variant="default">새 v{form.watch("revision")}</Badge> + </div> + <p className="text-sm"> + 기존 템플릿을 기반으로 새로운 리비전을 생성합니다. + <span className="text-red-500 mt-1 block">* 표시된 항목은 필수 입력사항입니다.</span> + </p> + </div> + </DialogDescription> + </DialogHeader> + + {/* 스크롤 가능한 컨텐츠 영역 */} + <div className="flex-1 overflow-y-auto px-6"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-4"> + {/* 리비전 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">리비전 정보</CardTitle> + <CardDescription> + 새로 생성할 리비전의 번호를 설정하세요 + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel> + 리비전 번호 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + type="number" + min={baseTemplate.revision + 1} + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || suggestedRevision)} + /> + </FormControl> + <FormDescription> + 권장 리비전: {suggestedRevision} (현재 리비전보다 큰 숫자여야 합니다) + </FormDescription> + <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"> + 적용 범위 <span className="text-red-500">*</span> + </CardTitle> + <CardDescription> + 이 리비전이 적용될 사업부를 선택하세요. ({selectedScopesCount}개 선택됨) + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-between"> + <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> + <Button + type="button" + variant="outline" + size="sm" + onClick={handleCopyPreviousSettings} + > + <Copy className="h-4 w-4 mr-1" /> + 이전 설정 복사 + </Button> + </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 CreateRevisionFormValues} + 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> + <CardDescription> + 새 리비전의 템플릿 파일을 업로드하세요 + </CardDescription> + </CardHeader> + <CardContent> + <FormField + control={form.control} + name="file" + render={() => ( + <FormItem> + <FormLabel> + 템플릿 파일 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Dropzone + onDrop={handleFileChange} + accept={{ + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'] + }} + > + <DropzoneZone> + <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" /> + <DropzoneTitle> + {selectedFile ? selectedFile.name : "워드 파일을 여기에 드래그하세요"} + </DropzoneTitle> + <DropzoneDescription> + {selectedFile + ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB` + : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 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> + </form> + </Form> + </div> + + {/* 고정된 푸터 */} + <DialogFooter className="p-6 pt-4 border-t"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="button" + onClick={form.handleSubmit(onSubmit)} + disabled={ + isLoading || + !form.watch("file") || + !BUSINESS_UNITS.some(unit => form.watch(unit.key as keyof CreateRevisionFormValues)) || + form.watch("revision") <= baseTemplate.revision + } + > + {isLoading && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "처리 중..." : "리비전 생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx index 88783461..66037601 100644 --- a/lib/basic-contract/template/update-basicContract-sheet.tsx +++ b/lib/basic-contract/template/update-basicContract-sheet.tsx @@ -36,7 +36,6 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet" -import { Input } from "@/components/ui/input" import { Dropzone, DropzoneZone, @@ -47,14 +46,16 @@ import { } 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" // 템플릿 이름 옵션 정의 const TEMPLATE_NAME_OPTIONS = [ - "준법서약", - "기술자료 요구서", + "준법서약 (한글)", + "준법서약 (영문)", + "기술자료 요구서", "비밀유지 계약서", "표준하도급기본 계약서", "General GTC", @@ -66,14 +67,13 @@ const TEMPLATE_NAME_OPTIONS = [ "직납자재 하도급대급등 연동제 의향서" ] as const; -// 업데이트 템플릿 스키마 정의 (templateCode, status 제거, 워드파일만 허용) +// 업데이트 템플릿 스키마 정의 (리비전 필드 제거, 워드파일만 허용) export const updateTemplateSchema = z.object({ templateName: z.enum(TEMPLATE_NAME_OPTIONS, { required_error: "템플릿 이름을 선택해주세요.", }), - revision: z.coerce.number().int().min(1, "리비전은 1 이상이어야 합니다."), legalReviewRequired: z.boolean(), - + // 적용 범위 shipBuildingApplicable: z.boolean(), windApplicable: z.boolean(), @@ -83,22 +83,22 @@ export const updateTemplateSchema = z.object({ gyApplicable: z.boolean(), sysApplicable: z.boolean(), infraApplicable: z.boolean(), - + file: z .instanceof(File, { message: "파일을 업로드해주세요." }) .refine((file) => file.size <= 100 * 1024 * 1024, { message: "파일 크기는 100MB 이하여야 합니다.", }) .refine( - (file) => - file.type === 'application/msword' || + (file) => + file.type === 'application/msword' || file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', { message: "워드 파일(.doc, .docx)만 업로드 가능합니다." } ) .optional(), }).refine((data) => { // 적어도 하나의 적용 범위는 선택되어야 함 - const hasAnyScope = BUSINESS_UNITS.some(unit => + const hasAnyScope = BUSINESS_UNITS.some(unit => data[unit.key as keyof typeof data] as boolean ); return hasAnyScope; @@ -122,8 +122,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem const form = useForm<UpdateTemplateSchema>({ resolver: zodResolver(updateTemplateSchema), defaultValues: { - templateName: template?.templateName as typeof TEMPLATE_NAME_OPTIONS[number] ?? "준법서약", - revision: template?.revision ?? 1, + templateName: template?.templateName as typeof TEMPLATE_NAME_OPTIONS[number] ?? "준법서약 (한글)", legalReviewRequired: template?.legalReviewRequired ?? false, shipBuildingApplicable: template?.shipBuildingApplicable ?? false, windApplicable: template?.windApplicable ?? false, @@ -147,9 +146,10 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem }; // 모든 적용 범위 선택/해제 - const handleSelectAllScopes = (checked: boolean) => { + const handleSelectAllScopes = (checked: boolean | "indeterminate") => { + const value = checked === true; BUSINESS_UNITS.forEach(unit => { - form.setValue(unit.key as keyof UpdateTemplateSchema, checked); + form.setValue(unit.key as keyof UpdateTemplateSchema, value); }); }; @@ -158,7 +158,6 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem if (template) { form.reset({ templateName: template.templateName as typeof TEMPLATE_NAME_OPTIONS[number], - revision: template.revision ?? 1, legalReviewRequired: template.legalReviewRequired ?? false, shipBuildingApplicable: template.shipBuildingApplicable ?? false, windApplicable: template.windApplicable ?? false, @@ -173,7 +172,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem }, [template, form]); // 현재 선택된 적용 범위 수 - const selectedScopesCount = BUSINESS_UNITS.filter(unit => + const selectedScopesCount = BUSINESS_UNITS.filter(unit => form.watch(unit.key as keyof UpdateTemplateSchema) ).length; @@ -181,22 +180,21 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem startUpdateTransition(async () => { if (!template) return - // FormData 객체 생성하여 파일과 데이터를 함께 전송 (templateCode, status 제거) + // FormData 객체 생성하여 파일과 데이터를 함께 전송 const formData = new FormData(); formData.append("templateName", input.templateName); - 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()); }); - + if (input.file) { formData.append("file", input.file); } - + try { // 서비스 함수 호출 const { error } = await updateTemplate({ @@ -223,6 +221,15 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem if (!template) return null; + const scopeSelected = BUSINESS_UNITS.some( + (unit) => form.watch(unit.key as keyof UpdateTemplateSchema) + ); + + const isDisabled = + isUpdatePending || + !form.watch("templateName") || + !scopeSelected; + return ( <Sheet {...props}> <SheetContent className="sm:max-w-[600px] h-[100vh] flex flex-col p-0"> @@ -234,7 +241,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> </SheetDescription> </SheetHeader> - + {/* 스크롤 가능한 컨텐츠 영역 */} <div className="flex-1 overflow-y-auto px-6"> <Form {...form}> @@ -247,11 +254,13 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem <CardHeader> <CardTitle className="text-lg">기본 정보</CardTitle> <CardDescription> + 현재 리비전: <Badge variant="outline">v{template.revision}</Badge> + <br /> 현재 적용 범위: {scopeHelpers.getScopeDisplayText(template)} </CardDescription> </CardHeader> <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="grid grid-cols-1 gap-4"> <FormField control={form.control} name="templateName" @@ -283,32 +292,6 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem </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> - 템플릿 버전을 업데이트하세요. - <br /> - <span className="text-xs text-muted-foreground"> - 동일한 템플릿 이름의 리비전은 중복될 수 없습니다. - </span> - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> </div> <FormField @@ -346,7 +329,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem </CardHeader> <CardContent className="space-y-4"> <div className="flex items-center space-x-2"> - <Checkbox + <Checkbox id="select-all" checked={selectedScopesCount === BUSINESS_UNITS.length} onCheckedChange={handleSelectAllScopes} @@ -355,9 +338,9 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem 전체 선택 </label> </div> - + <Separator /> - + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> {BUSINESS_UNITS.map((unit) => ( <FormField @@ -382,7 +365,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem /> ))} </div> - + {form.formState.errors.shipBuildingApplicable && ( <p className="text-sm text-destructive"> {form.formState.errors.shipBuildingApplicable.message} @@ -417,13 +400,13 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem <DropzoneZone> <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" /> <DropzoneTitle> - {selectedFile - ? selectedFile.name + {selectedFile + ? selectedFile.name : "새 워드 파일을 드래그하세요 (선택사항)"} </DropzoneTitle> <DropzoneDescription> - {selectedFile - ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB` + {selectedFile + ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB` : "또는 클릭하여 워드 파일(.doc, .docx)을 선택하세요 (최대 100MB)"} </DropzoneDescription> <DropzoneInput /> @@ -447,16 +430,13 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem 취소 </Button> </SheetClose> - <Button + <Button type="button" onClick={form.handleSubmit(onSubmit)} - disabled={isUpdatePending || !form.formState.isValid} + disabled={isDisabled} > {isUpdatePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> )} 저장 </Button> diff --git a/lib/basic-contract/validations.ts b/lib/basic-contract/validations.ts index 5a5bf5b8..39248b4a 100644 --- a/lib/basic-contract/validations.ts +++ b/lib/basic-contract/validations.ts @@ -51,7 +51,46 @@ export const searchParamsTemplatesCache = createSearchParamsCache({ })
-export const createBasicContractTemplateSchema = basicContractTemplateSchema.extend({});
+export const BUSINESS_UNIT_KEYS = [
+ "shipBuildingApplicable",
+ "windApplicable",
+ "pcApplicable",
+ "nbApplicable",
+ "rcApplicable",
+ "gyApplicable",
+ "sysApplicable",
+ "infraApplicable",
+] as const;
+
+export const createBasicContractTemplateSchema = z.object({
+ templateName: z.string().min(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),
+
+ status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"),
+ fileName: z.string().min(1),
+ filePath: z.string().min(1),
+
+ // 기존에 쓰시던 validityPeriod 를 계속 쓰실 거라면 남기고, 아니라면 지우세요.
+ // 예: 문자열(YYYY-MM-DD ~ YYYY-MM-DD) 또는 number(개월 수) 등 구체화 필요
+ validityPeriod: z.string().optional(),
+}).refine((data) => {
+ // 최소 1개 이상 사업부 선택
+ return BUSINESS_UNIT_KEYS.some((k) => data[k] === true);
+}, {
+ message: "적어도 하나의 적용 범위를 선택해야 합니다.",
+ path: ["shipBuildingApplicable"], // 첫 체크박스에 에러 표시 유도
+});
+
+export type CreateBasicContractTemplateSchema = z.infer<typeof createBasicContractTemplateSchema>;
export const updateBasicContractTemplateSchema = basicContractTemplateSchema.partial().extend({
id: z.number(),
@@ -64,7 +103,6 @@ export const deleteBasicContractTemplateSchema = z.object({ export type GetBasicContractTemplatesSchema = Awaited<ReturnType<typeof searchParamsTemplatesCache.parse>>
-export type CreateBasicContractTemplateSchema = z.infer<typeof createBasicContractTemplateSchema>;
export type UpdateBasicContractTemplateSchema = z.infer<typeof updateBasicContractTemplateSchema>;
export type DeleteBasicContractTemplateSchema = z.infer<typeof deleteBasicContractTemplateSchema>;
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts index 1c133e3a..8d890604 100644 --- a/lib/evaluation-target-list/service.ts +++ b/lib/evaluation-target-list/service.ts @@ -73,79 +73,63 @@ export async function countEvaluationTargetsFromView( // ============= 메인 서버 액션도 함께 수정 ============= export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) { try { + console.log("=== 서버 액션 호출 ==="); + console.log("필터 수:", input.filters?.length || 0); + console.log("조인 연산자:", input.joinOperator); + const offset = (input.page - 1) * input.perPage; - // ✅ getRFQ 방식과 동일한 필터링 처리 - // 1) 고급 필터 조건 + // ✅ 단순화된 필터 처리 let advancedWhere: SQL<unknown> | undefined = undefined; - if (input.filters && input.filters.length > 0) { - advancedWhere = filterColumns({ - table: evaluationTargetsWithDepartments, - filters: input.filters, - joinOperator: input.joinOperator || 'and', - }); - } - - // 2) 기본 필터 조건 - let basicWhere: SQL<unknown> | undefined = undefined; - if (input.basicFilters && input.basicFilters.length > 0) { - basicWhere = filterColumns({ - table: evaluationTargetsWithDepartments, - filters: input.basicFilters, - joinOperator: input.basicJoinOperator || 'and', - }); + + if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) { + console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`)); + + try { + advancedWhere = filterColumns({ + table: evaluationTargetsWithDepartments, + filters: input.filters, + joinOperator: input.joinOperator || 'and', + }); + + console.log("필터 조건 생성 완료"); + } catch (error) { + console.error("필터 조건 생성 오류:", error); + // 필터 오류 시에도 전체 데이터 반환 + advancedWhere = undefined; + } } - // 3) 글로벌 검색 조건 + // 2) 글로벌 검색 조건 let globalWhere: SQL<unknown> | undefined = undefined; if (input.search) { const s = `%${input.search}%`; - const validSearchConditions: SQL<unknown>[] = []; - - const vendorCodeCondition = ilike(evaluationTargetsWithDepartments.vendorCode, s); - if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); - - const vendorNameCondition = ilike(evaluationTargetsWithDepartments.vendorName, s); - if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); - - const adminCommentCondition = ilike(evaluationTargetsWithDepartments.adminComment, s); - if (adminCommentCondition) validSearchConditions.push(adminCommentCondition); - - const consolidatedCommentCondition = ilike(evaluationTargetsWithDepartments.consolidatedComment, s); - if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition); - - // 담당자 이름으로도 검색 - const orderReviewerCondition = ilike(evaluationTargetsWithDepartments.orderReviewerName, s); - if (orderReviewerCondition) validSearchConditions.push(orderReviewerCondition); - - const procurementReviewerCondition = ilike(evaluationTargetsWithDepartments.procurementReviewerName, s); - if (procurementReviewerCondition) validSearchConditions.push(procurementReviewerCondition); - - const qualityReviewerCondition = ilike(evaluationTargetsWithDepartments.qualityReviewerName, s); - if (qualityReviewerCondition) validSearchConditions.push(qualityReviewerCondition); - - const designReviewerCondition = ilike(evaluationTargetsWithDepartments.designReviewerName, s); - if (designReviewerCondition) validSearchConditions.push(designReviewerCondition); - - const csReviewerCondition = ilike(evaluationTargetsWithDepartments.csReviewerName, s); - if (csReviewerCondition) validSearchConditions.push(csReviewerCondition); - - if (validSearchConditions.length > 0) { - globalWhere = or(...validSearchConditions); + const searchConditions: SQL<unknown>[] = [ + ilike(evaluationTargetsWithDepartments.vendorCode, s), + ilike(evaluationTargetsWithDepartments.vendorName, s), + ilike(evaluationTargetsWithDepartments.adminComment, s), + ilike(evaluationTargetsWithDepartments.consolidatedComment, s), + ilike(evaluationTargetsWithDepartments.orderReviewerName, s), + ilike(evaluationTargetsWithDepartments.procurementReviewerName, s), + ilike(evaluationTargetsWithDepartments.qualityReviewerName, s), + ilike(evaluationTargetsWithDepartments.designReviewerName, s), + ilike(evaluationTargetsWithDepartments.csReviewerName, s), + ].filter(Boolean); + + if (searchConditions.length > 0) { + globalWhere = or(...searchConditions); } } - // ✅ getRFQ 방식과 동일한 WHERE 조건 생성 + // 3) 최종 WHERE 조건 결합 const whereConditions: SQL<unknown>[] = []; - if (advancedWhere) whereConditions.push(advancedWhere); - if (basicWhere) whereConditions.push(basicWhere); if (globalWhere) whereConditions.push(globalWhere); const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; - // ✅ getRFQ 방식과 동일한 전체 데이터 수 조회 (Transaction 제거) + // 4) 전체 데이터 수 조회 const totalResult = await db .select({ count: count() }) .from(evaluationTargetsWithDepartments) @@ -157,12 +141,14 @@ export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) { return { data: [], pageCount: 0, total: 0 }; } - console.log("Total evaluation targets:", total); + console.log("총 데이터 수:", total); - // ✅ getRFQ 방식과 동일한 정렬 및 페이징 처리된 데이터 조회 + // 5) 정렬 및 페이징 처리 const orderByColumns = input.sort.map((sort) => { const column = sort.id as keyof typeof evaluationTargetsWithDepartments.$inferSelect; - return sort.desc ? desc(evaluationTargetsWithDepartments[column]) : asc(evaluationTargetsWithDepartments[column]); + return sort.desc + ? desc(evaluationTargetsWithDepartments[column]) + : asc(evaluationTargetsWithDepartments[column]); }); if (orderByColumns.length === 0) { @@ -179,10 +165,11 @@ export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) { const pageCount = Math.ceil(total / input.perPage); + console.log("반환 데이터 수:", evaluationData.length); + return { data: evaluationData, pageCount, total }; } catch (err) { - console.error("Error in getEvaluationTargets:", err); - // ✅ getRFQ 방식과 동일한 에러 반환 (total 포함) + console.error("getEvaluationTargets 오류:", err); return { data: [], pageCount: 0, total: 0 }; } } @@ -440,8 +427,6 @@ export interface UpdateEvaluationTargetInput { } export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) { - console.log(input, "update input") - try { const session = await getServerSession(authOptions) @@ -542,8 +527,8 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) { departmentCode: EVALUATION_DEPARTMENT_CODES.ORDER_EVAL, isApproved: input.orderIsApproved }, { departmentCode: EVALUATION_DEPARTMENT_CODES.PROCUREMENT_EVAL, isApproved: input.procurementIsApproved }, { departmentCode: EVALUATION_DEPARTMENT_CODES.QUALITY_EVAL, isApproved: input.qualityIsApproved }, - { departmentCode: EVALUATION_DEPARTMENT_CODES.DESIGN_EVAL, isApproved: input.designIsApproved }, - { departmentCode: EVALUATION_DEPARTMENT_CODES.CS_EVAL, isApproved: input.csIsApproved }, + // { departmentCode: EVALUATION_DEPARTMENT_CODES.DESIGN_EVAL, isApproved: input.designIsApproved }, + // { departmentCode: EVALUATION_DEPARTMENT_CODES.CS_EVAL, isApproved: input.csIsApproved }, ] for (const review of reviewUpdates) { @@ -589,46 +574,43 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) } } - // 4. 의견 일치 상태 및 전체 상태 자동 계산 + const requiredDepartments = [ + EVALUATION_DEPARTMENT_CODES.ORDER_EVAL, + EVALUATION_DEPARTMENT_CODES.PROCUREMENT_EVAL, + EVALUATION_DEPARTMENT_CODES.QUALITY_EVAL + ] + const currentReviews = await tx .select({ isApproved: evaluationTargetReviews.isApproved, departmentCode: evaluationTargetReviews.departmentCode, }) .from(evaluationTargetReviews) - .where(eq(evaluationTargetReviews.evaluationTargetId, input.id)) - - - const evaluationTargetForConcensus = await tx - .select({ - materialType: evaluationTargets.materialType, - }) - .from(evaluationTargets) - .where(eq(evaluationTargets.id, input.id)) - .limit(1) - - if (evaluationTargetForConcensus.length === 0) { - throw new Error("평가 대상을 찾을 수 없습니다.") - } - - const { materialType } = evaluationTargetForConcensus[0] - const minimumReviewsRequired = materialType === "BULK" ? 3 : 5 + .where( + and( + eq(evaluationTargetReviews.evaluationTargetId, input.id), + inArray(evaluationTargetReviews.departmentCode, requiredDepartments) + ) + ) + // 3개 필수 부서의 리뷰가 모두 완료된 경우에만 의견 일치 상태 계산 + const reviewedDepartments = currentReviews.map(r => r.departmentCode) + const allRequiredDepartmentsReviewed = requiredDepartments.every(dept => + reviewedDepartments.includes(dept) + ) - // 최소 3개 부서에서 평가가 완료된 경우 의견 일치 상태 계산 - if (currentReviews.length >= minimumReviewsRequired) { + if (allRequiredDepartmentsReviewed) { const approvals = currentReviews.map(r => r.isApproved) const allApproved = approvals.every(approval => approval === true) const allRejected = approvals.every(approval => approval === false) const hasConsensus = allApproved || allRejected - // let newStatus: "PENDING" | "CONFIRMED" | "EXCLUDED" = "PENDING" - // if (hasConsensus) { - // newStatus = allApproved ? "CONFIRMED" : "EXCLUDED" - // } - - // console.log("Auto-updating status:", { hasConsensus, newStatus, approvals }) - console.log("Auto-updating status:", { hasConsensus, approvals }) + console.log("Auto-updating consensus status:", { + hasConsensus, + approvals, + reviewedDepartments, + allRequiredDepartmentsReviewed + }) await tx .update(evaluationTargets) @@ -954,7 +936,7 @@ export async function confirmEvaluationTargets( return { success: true, - message: `${result.totalConfirmed}개 평가 대상이 확정되었습니다. ${result.createdEvaluationsCount}개의 정기평가와 ${result.createdSubmissionsCount}개의 제출 요청이 생성되었습니다.`, + message: `${result.totalConfirmed}개 평가 대상이 확정되었습니다. ${result.createdEvaluationsCount}개의 정기평가가 생성되었습니다.`, confirmedCount: result.totalConfirmed, createdEvaluationsCount: result.createdEvaluationsCount, // createdSubmissionsCount: result.createdSubmissionsCount @@ -1377,4 +1359,103 @@ export async function autoGenerateEvaluationTargets( message: "평가 대상 자동 생성에 실패했습니다." } } -}
\ No newline at end of file +} + + +export async function deleteEvaluationTargets(targetIds: number[]) { + console.log(targetIds, "targetIds to delete"); + + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + throw new Error("로그인이 필요합니다."); + } + + // 권한 체크 (필요한 경우) + // const hasPermission = await checkUserPermission(session.user.id, 'manage_evaluations'); + // if (!hasPermission) { + // throw new Error("평가 관리 권한이 없습니다."); + // } + + return await db.transaction(async (tx) => { + // 1. 삭제하려는 타겟들이 존재하고 PENDING 상태인지 확인 + const targetsToDelete = await tx + .select({ + id: evaluationTargets.id, + status: evaluationTargets.status, + vendorName: evaluationTargets.vendorName, + evaluationYear: evaluationTargets.evaluationYear, + division: evaluationTargets.division, + }) + .from(evaluationTargets) + .where(inArray(evaluationTargets.id, targetIds)); + + if (targetsToDelete.length === 0) { + throw new Error("삭제할 평가 대상을 찾을 수 없습니다."); + } + + // PENDING 상태가 아닌 타겟들 확인 + const nonPendingTargets = targetsToDelete.filter(target => target.status !== 'PENDING'); + if (nonPendingTargets.length > 0) { + const nonPendingNames = nonPendingTargets + .map(t => `${t.vendorName} (${t.evaluationYear}년)`) + .join(', '); + throw new Error(`다음 평가 대상은 PENDING 상태가 아니어서 삭제할 수 없습니다: ${nonPendingNames}`); + } + + // 실제로 삭제할 수 있는 타겟 ID들 + const validTargetIds = targetsToDelete.map(t => t.id); + + console.log(`Deleting ${validTargetIds.length} evaluation targets:`, + targetsToDelete.map(t => `${t.vendorName} (${t.evaluationYear}년, ${t.division})`)); + + // 2. 관련된 자식 테이블들 먼저 삭제 + // evaluationTargetReviewers 테이블 삭제 + const deletedReviewers = await tx + .delete(evaluationTargetReviewers) + .where(inArray(evaluationTargetReviewers.evaluationTargetId, validTargetIds)) + .returning({ id: evaluationTargetReviewers.id }); + + console.log(`Deleted ${deletedReviewers.length} reviewer assignments`); + + // 3. 기타 관련 테이블들 삭제 (필요한 경우 추가) + // 예: evaluationTargetDepartments, evaluationComments 등 + // const deletedDepartments = await tx + // .delete(evaluationTargetDepartments) + // .where(inArray(evaluationTargetDepartments.evaluationTargetId, validTargetIds)) + // .returning({ id: evaluationTargetDepartments.id }); + + // 4. 메인 테이블 삭제 + const deletedTargets = await tx + .delete(evaluationTargets) + .where(inArray(evaluationTargets.id, validTargetIds)) + .returning({ + id: evaluationTargets.id, + vendorName: evaluationTargets.vendorName, + evaluationYear: evaluationTargets.evaluationYear + }); + + console.log(`Successfully deleted ${deletedTargets.length} evaluation targets`); + + return { + success: true, + deletedCount: deletedTargets.length, + deletedTargets: deletedTargets.map(t => ({ + id: t.id, + vendorName: t.vendorName, + evaluationYear: t.evaluationYear + })), + message: `${deletedTargets.length}개의 평가 대상이 성공적으로 삭제되었습니다.`, + }; + }); + + } catch (error) { + console.error("Error deleting evaluation targets:", error); + return { + success: false, + error: error instanceof Error ? error.message : "평가 대상 삭제 중 오류가 발생했습니다.", + }; + } +} + diff --git a/lib/evaluation-target-list/table/delete-targets-dialog.tsx b/lib/evaluation-target-list/table/delete-targets-dialog.tsx new file mode 100644 index 00000000..5414d281 --- /dev/null +++ b/lib/evaluation-target-list/table/delete-targets-dialog.tsx @@ -0,0 +1,181 @@ +"use client" + +import * as React from "react" +import { Trash2, AlertTriangle } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { EvaluationTargetWithDepartments } from "@/db/schema" +import { deleteEvaluationTargets } from "../service" +interface DeleteTargetsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + targets: EvaluationTargetWithDepartments[] + onSuccess?: () => void +} + +export function DeleteTargetsDialog({ + open, + onOpenChange, + targets, + onSuccess +}: DeleteTargetsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + // PENDING 상태인 타겟들만 필터링 (추가 안전장치) + const pendingTargets = React.useMemo(() => { + return targets.filter(target => target.status === "PENDING") + }, [targets]) + + console.log(pendingTargets,"pendingTargets") + + const handleDelete = async () => { + if (pendingTargets.length === 0) { + toast.error("삭제할 수 있는 평가 대상이 없습니다.") + return + } + + setIsLoading(true) + try { + const targetIds = pendingTargets.map(target => target.id) + const result = await deleteEvaluationTargets(targetIds) + + if (result.success) { + toast.success(result.message || "평가 대상이 성공적으로 삭제되었습니다.", { + description: `${result.deletedCount || pendingTargets.length}개의 항목이 삭제되었습니다.` + }) + onSuccess?.() + onOpenChange(false) + } else { + toast.error(result.error || "삭제 중 오류가 발생했습니다.") + } + } catch (error) { + console.error('Error deleting targets:', error) + toast.error("삭제 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const handleCancel = () => { + if (!isLoading) { + onOpenChange(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Trash2 className="size-5 text-destructive" /> + 평가 대상 삭제 + </DialogTitle> + <DialogDescription> + 선택한 평가 대상을 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다. + </DialogDescription> + </DialogHeader> + + {pendingTargets.length > 0 ? ( + <div className="space-y-4"> + {/* 경고 메시지 */} + <div className="flex items-start gap-3 p-4 bg-destructive/10 rounded-lg border border-destructive/20"> + <AlertTriangle className="size-5 text-destructive mt-0.5 flex-shrink-0" /> + <div className="space-y-1"> + <p className="font-medium text-destructive"> + 주의: 삭제된 데이터는 복구할 수 없습니다 + </p> + <p className="text-sm text-muted-foreground"> + PENDING 상태의 평가 대상만 삭제할 수 있습니다. + 확정(CONFIRMED)되거나 제외(EXCLUDED)된 대상은 삭제할 수 없습니다. + </p> + </div> + </div> + + {/* 삭제 대상 목록 */} + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h4 className="font-medium">삭제될 평가 대상 ({pendingTargets.length}개)</h4> + <Badge variant="destructive" className="gap-1"> + <Trash2 className="size-3" /> + 삭제 예정 + </Badge> + </div> + + <ScrollArea className="h-40 w-full border rounded-md"> + <div className="p-4 space-y-2"> + {pendingTargets.map((target) => ( + <div + key={target.id} + className="flex items-center justify-between p-2 bg-muted/50 rounded text-sm" + > + <div className="space-y-1"> + <div className="font-medium"> + {target.vendorName || '알 수 없는 업체'} + </div> + <div className="text-xs text-muted-foreground"> + • {target.evaluationYear}년 + </div> + </div> + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {target.status} + </Badge> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + </div> + ) : ( + <div className="flex items-center justify-center py-8 text-muted-foreground"> + <div className="text-center space-y-2"> + <Trash2 className="size-8 mx-auto opacity-50" /> + <p>삭제할 수 있는 평가 대상이 없습니다.</p> + <p className="text-xs">PENDING 상태의 대상만 삭제할 수 있습니다.</p> + </div> + </div> + )} + + <DialogFooter> + <Button + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + variant="destructive" + onClick={handleDelete} + disabled={isLoading || pendingTargets.length === 0} + className="gap-2" + > + {isLoading ? ( + <> + <div className="size-4 border-2 border-current border-r-transparent rounded-full animate-spin" /> + 삭제 중... + </> + ) : ( + <> + <Trash2 className="size-4" /> + {pendingTargets.length}개 삭제 + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx index 5560d3ff..c65a7815 100644 --- a/lib/evaluation-target-list/table/evaluation-target-table.tsx +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -1,6 +1,6 @@ // ============================================================================ // components/evaluation-targets-table.tsx (CLIENT COMPONENT) -// ─ 완전본 ─ evaluation-targets-columns.tsx 는 별도 +// ─ 정리된 버전 ─ // ============================================================================ "use client"; @@ -18,14 +18,14 @@ import type { } from "@/types/table"; import { useDataTable } from "@/hooks/use-data-table"; import { DataTable } from "@/components/data-table/data-table"; -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; // ✅ 확장된 버전 사용 import { getEvaluationTargets, getEvaluationTargetsStats } from "../service"; import { cn } from "@/lib/utils"; import { useTablePresets } from "@/components/data-table/use-table-presets"; import { TablePresetManager } from "@/components/data-table/data-table-preset"; import { getEvaluationTargetsColumns } from "./evaluation-targets-columns"; import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions"; -import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet"; +import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet"; // ✅ 폼 기반 필터 시트 import { EvaluationTargetWithDepartments } from "@/db/schema"; import { EditEvaluationTargetSheet } from "./update-evaluation-target"; import { @@ -33,6 +33,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { useRouter } from "next/navigation"; // ✅ 라우터 추가 /* -------------------------------------------------------------------------- */ /* Process Guide Popover */ @@ -239,11 +240,93 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); const searchParams = useSearchParams(); + // ✅ 외부 필터 상태 (폼에서 전달받은 필터) + const [externalFilters, setExternalFilters] = React.useState<any[]>([]); + const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); + + // ✅ 폼에서 전달받은 필터를 처리하는 핸들러 + const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { + console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); + setExternalFilters(filters); + setExternalJoinOperator(joinOperator); + // 필터 적용 후 패널 닫기 + setIsFilterPanelOpen(false); + }, []); + + + const searchString = React.useMemo( + () => searchParams.toString(), + [searchParams] + ); + + const getSearchParam = React.useCallback( + (key: string, def = "") => + new URLSearchParams(searchString).get(key) ?? def, + [searchString] + ); + + + // ✅ URL 필터 변경 감지 및 데이터 새로고침 + React.useEffect(() => { + const refetchData = async () => { + try { + setIsDataLoading(true); + + // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성 + const currentFilters = getSearchParam("filters"); + const currentJoinOperator = getSearchParam("joinOperator", "and"); + const currentPage = parseInt(getSearchParam("page", "1")); + const currentPerPage = parseInt(getSearchParam("perPage", "10")); + const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; + const currentSearch = getSearchParam("search", ""); + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + evaluationYear: evaluationYear + }; + + console.log("=== 새 데이터 요청 ===", searchParams); + + // 서버 액션 직접 호출 + const newData = await getEvaluationTargets(searchParams); + setTableData(newData); + + console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건"); + } catch (error) { + console.error("데이터 새로고침 오류:", error); + } finally { + setIsDataLoading(false); + } + }; + + /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ + + // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용) + const timeoutId = setTimeout(() => { + // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침 + const hasChanges = getSearchParam("filters") || + getSearchParam("search") || + getSearchParam("page") !== "1" || + getSearchParam("perPage") !== "10" || + getSearchParam("sort"); + + if (hasChanges) { + refetchData(); + } + }, 300); // 디바운스 시간 단축 + + return () => clearTimeout(timeoutId); + }, [searchString, evaluationYear, getSearchParam]); + /* --------------------------- layout refs --------------------------- */ const containerRef = React.useRef<HTMLDivElement>(null); const [containerTop, setContainerTop] = React.useState(0); - // RFQ 패턴으로 변경: State를 통한 위치 관리 const updateContainerBounds = React.useCallback(() => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); @@ -267,25 +350,16 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: }; }, [updateContainerBounds]); - /* ---------------------- 데이터 프리패치 ---------------------- */ - const [promiseData] = React.use(promises); - const tableData = promiseData; + /* ---------------------- 데이터 상태 관리 ---------------------- */ + // 초기 데이터 설정 + const [initialPromiseData] = React.use(promises); + + // ✅ 테이블 데이터 상태 추가 + const [tableData, setTableData] = React.useState(initialPromiseData); + const [isDataLoading, setIsDataLoading] = React.useState(false); - console.log(tableData) - /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ - const searchString = React.useMemo( - () => searchParams.toString(), // query가 바뀔 때만 새로 계산 - [searchParams] - ); - const getSearchParam = React.useCallback( - (key: string, def = "") => - new URLSearchParams(searchString).get(key) ?? def, - [searchString] - ); - - // 제네릭 함수는 useCallback 밖에서 정의 const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { try { const value = getSearchParam(key); @@ -295,9 +369,9 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: } }, [getSearchParam]); -const parseSearchParam = <T,>(key: string, defaultValue: T): T => { - return parseSearchParamHelper(key, defaultValue); -}; + const parseSearchParam = <T,>(key: string, defaultValue: T): T => { + return parseSearchParamHelper(key, defaultValue); + }; /* ---------------------- 초기 설정 ---------------------------- */ const initialSettings = React.useMemo(() => ({ @@ -306,15 +380,13 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => { sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }], filters: parseSearchParam("filters", []), joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and", - basicFilters: parseSearchParam("basicFilters", []), - basicJoinOperator: (getSearchParam("basicJoinOperator") as "and" | "or") || "and", search: getSearchParam("search", ""), columnVisibility: {}, columnOrder: [], pinnedColumns: { left: [], right: ["actions"] }, groupBy: [], expandedRows: [], - }), [getSearchParam]); + }), [getSearchParam, parseSearchParam]); /* --------------------- 프리셋 훅 ------------------------------ */ const { @@ -336,14 +408,6 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => { /* --------------------- 컬럼 ------------------------------ */ const columns = React.useMemo(() => getEvaluationTargetsColumns({ setRowAction }), [setRowAction]); -// const columns =[ -// { accessorKey: "vendorCode", header: "벤더 코드" }, -// { accessorKey: "vendorName", header: "벤더명" }, -// { accessorKey: "status", header: "상태" }, -// { accessorKey: "evaluationYear", header: "평가년도" }, -// { accessorKey: "division", header: "구분" } -// ]; - /* 기본 필터 */ const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [ @@ -355,13 +419,36 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => { /* 고급 필터 */ const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [ { id: "evaluationYear", label: "평가년도", type: "number" }, - { id: "division", label: "구분", type: "select", options: [ { label: "해양", value: "OCEAN" }, { label: "조선", value: "SHIPYARD" } ] }, + { id: "division", label: "구분", type: "select", options: [ + { label: "해양", value: "PLANT" }, + { label: "조선", value: "SHIP" } + ]}, { id: "vendorCode", label: "벤더 코드", type: "text" }, { id: "vendorName", label: "벤더명", type: "text" }, - { id: "domesticForeign", label: "내외자", type: "select", options: [ { label: "내자", value: "DOMESTIC" }, { label: "외자", value: "FOREIGN" } ] }, - { id: "materialType", label: "자재구분", type: "select", options: [ { label: "기자재", value: "EQUIPMENT" }, { label: "벌크", value: "BULK" }, { label: "기/벌", value: "EQUIPMENT_BULK" } ] }, - { id: "status", label: "상태", type: "select", options: [ { label: "검토 중", value: "PENDING" }, { label: "확정", value: "CONFIRMED" }, { label: "제외", value: "EXCLUDED" } ] }, - { id: "consensusStatus", label: "의견 일치", type: "select", options: [ { label: "일치", value: "true" }, { label: "불일치", value: "false" }, { label: "검토 중", value: "null" } ] }, + { id: "domesticForeign", label: "내외자", type: "select", options: [ + { label: "내자", value: "DOMESTIC" }, + { label: "외자", value: "FOREIGN" } + ]}, + { id: "materialType", label: "자재구분", type: "select", options: [ + { label: "기자재", value: "EQUIPMENT" }, + { label: "벌크", value: "BULK" }, + { label: "기자재/벌크", value: "EQUIPMENT_BULK" } + ]}, + { id: "status", label: "상태", type: "select", options: [ + { label: "검토 중", value: "PENDING" }, + { label: "확정", value: "CONFIRMED" }, + { label: "제외", value: "EXCLUDED" } + ]}, + { id: "consensusStatus", label: "의견 일치", type: "select", options: [ + { label: "일치", value: "true" }, + { label: "불일치", value: "false" }, + { label: "검토 중", value: "null" } + ]}, + { id: "orderReviewerName", label: "발주 담당자명", type: "text" }, + { id: "procurementReviewerName", label: "조달 담당자명", type: "text" }, + { id: "qualityReviewerName", label: "품질 담당자명", type: "text" }, + { id: "designReviewerName", label: "설계 담당자명", type: "text" }, + { id: "csReviewerName", label: "CS 담당자명", type: "text" }, { id: "adminComment", label: "관리자 의견", type: "text" }, { id: "consolidatedComment", label: "종합 의견", type: "text" }, { id: "confirmedAt", label: "확정일", type: "date" }, @@ -398,10 +485,15 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => { }); /* ---------------------- helper ------------------------------ */ - const getActiveBasicFilterCount = React.useCallback(() => { + const getActiveFilterCount = React.useCallback(() => { try { - const f = getSearchParam("basicFilters"); - return f ? JSON.parse(f).length : 0; + // URL에서 현재 필터 수 확인 + const filtersParam = getSearchParam("filters"); + if (filtersParam) { + const filters = JSON.parse(filtersParam); + return Array.isArray(filters) ? filters.length : 0; + } + return 0; } catch { return 0; } @@ -427,7 +519,7 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => { <EvaluationTargetFilterSheet isOpen={isFilterPanelOpen} onClose={() => setIsFilterPanelOpen(false)} - onSearch={() => setIsFilterPanelOpen(false)} + onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달 isLoading={false} /> </div> @@ -451,9 +543,9 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => { className="flex items-center shadow-sm" > {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />} - {getActiveBasicFilterCount() > 0 && ( + {getActiveFilterCount() > 0 && ( <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> - {getActiveBasicFilterCount()} + {getActiveFilterCount()} </span> )} </Button> @@ -468,12 +560,27 @@ const parseSearchParam = <T,>(key: string, defaultValue: T): T => { </div> {/* Table */} - <div className="flex-1 overflow-hidden" style={{ height: "calc(100vh - 500px)" }}> + <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}> + {isDataLoading && ( + <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" /> + 필터링 중... + </div> + </div> + )} <DataTable table={table} className="h-full"> + {/* ✅ 확장된 DataTableAdvancedToolbar 사용 */} <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} + debounceMs={300} shallow={false} + externalFilters={externalFilters} + externalJoinOperator={externalJoinOperator} + onFiltersChange={(filters, joinOperator) => { + console.log("=== 필터 변경 감지 ===", filters, joinOperator); + }} > <div className="flex items-center gap-2"> <TablePresetManager<EvaluationTargetWithDepartments> diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx index c3aa9d71..7b6754c1 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -210,18 +210,27 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col header: createHeaderRenderer("평가년도"), cell: renderEvaluationYear, size: 100, + meta: { + excelHeader: "평가년도", + }, }, { accessorKey: "division", header: createHeaderRenderer("구분"), cell: renderDivision, size: 80, + meta: { + excelHeader: "구분", + }, }, { accessorKey: "status", header: createHeaderRenderer("상태"), cell: renderStatus, size: 100, + meta: { + excelHeader: "상태", + }, }, @@ -235,24 +244,36 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col header: createHeaderRenderer("벤더 코드"), cell: renderVendorCode, size: 120, + meta: { + excelHeader: "벤더 코드", + }, }, { accessorKey: "vendorName", header: createHeaderRenderer("벤더명"), cell: renderVendorName, size: 200, + meta: { + excelHeader: "벤더명", + }, }, { accessorKey: "domesticForeign", header: createHeaderRenderer("내외자"), cell: renderDomesticForeign, size: 80, + meta: { + excelHeader: "내외자", + }, }, { accessorKey: "materialType", header: createHeaderRenderer("자재구분"), cell: renderMaterialType, size: 120, + meta: { + excelHeader: "자재구분", + }, }, ] }, @@ -262,6 +283,9 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col header: createHeaderRenderer("의견 일치"), cell: renderConsensusStatus, size: 100, + meta: { + excelHeader: "의견 일치", + }, }, { @@ -275,6 +299,9 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col <span className="text-sm">{row.original.ldClaimCount}</span> ), size: 80, + meta: { + excelHeader: "LD 건수", + }, }, { @@ -284,6 +311,9 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col <span className="font-mono text-sm">{(Number(row.original.ldClaimAmount).toLocaleString())}</span> ), size: 80, + meta: { + excelHeader: "LD 금액", + }, }, { accessorKey: "ldClaimCurrency", @@ -293,6 +323,9 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col <span className="text-sm">{row.original.ldClaimCurrency}</span> ), size: 80, + meta: { + excelHeader: "LD 금액 단위", + }, }, ] @@ -308,12 +341,18 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col header: createHeaderRenderer("담당자명"), cell: renderReviewerName("orderReviewerName"), size: 120, + meta: { + excelHeader: "발주 담당자명", + }, }, { accessorKey: "orderIsApproved", header: createHeaderRenderer("평가 대상"), cell: renderIsApproved("orderIsApproved"), size: 120, + meta: { + excelHeader: "발주 평가 대상", + }, }, ] }, @@ -328,12 +367,18 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col header: createHeaderRenderer("담당자명"), cell: renderReviewerName("procurementReviewerName"), size: 120, + meta: { + excelHeader: "조달 담당자명", + }, }, { accessorKey: "procurementIsApproved", header: createHeaderRenderer("평가 대상"), cell: renderIsApproved("procurementIsApproved"), size: 120, + meta: { + excelHeader: "조달 평가 대상", + }, }, ] }, @@ -348,12 +393,18 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col header: createHeaderRenderer("담당자명"), cell: renderReviewerName("qualityReviewerName"), size: 120, + meta: { + excelHeader: "품질 담당자명", + }, }, { accessorKey: "qualityIsApproved", header: createHeaderRenderer("평가 대상"), cell: renderIsApproved("qualityIsApproved"), size: 120, + meta: { + excelHeader: "품질 평가 대상", + }, }, ] }, @@ -369,12 +420,12 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col cell: renderReviewerName("designReviewerName"), size: 120, }, - { - accessorKey: "designIsApproved", - header: createHeaderRenderer("평가 대상"), - cell: renderIsApproved("designIsApproved"), - size: 120, - }, + // { + // accessorKey: "designIsApproved", + // header: createHeaderRenderer("평가 대상"), + // cell: renderIsApproved("designIsApproved"), + // size: 120, + // }, ] }, @@ -389,12 +440,12 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col cell: renderReviewerName("csReviewerName"), size: 120, }, - { - accessorKey: "csIsApproved", - header: createHeaderRenderer("평가 대상"), - cell: renderIsApproved("csIsApproved"), - size: 120, - }, + // { + // accessorKey: "csIsApproved", + // header: createHeaderRenderer("평가 대상"), + // cell: renderIsApproved("csIsApproved"), + // size: 120, + // }, ] }, @@ -404,24 +455,36 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col header: createHeaderRenderer("관리자 의견"), cell: renderComment("max-w-[150px]"), size: 150, + meta: { + excelHeader: "관리자 의견", + }, }, { accessorKey: "consolidatedComment", header: createHeaderRenderer("종합 의견"), cell: renderComment("max-w-[150px]"), size: 150, + meta: { + excelHeader: "종합 의견", + }, }, { accessorKey: "confirmedAt", header: createHeaderRenderer("확정일"), cell: renderConfirmedAt, size: 100, + meta: { + excelHeader: "확정일", + }, }, { accessorKey: "createdAt", header: createHeaderRenderer("생성일"), cell: renderCreatedAt, size: 100, + meta: { + excelHeader: "생성일", + }, }, // Actions diff --git a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx index c37258ae..3b6f9fa1 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx @@ -7,7 +7,6 @@ import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { Search, X } from "lucide-react" import { customAlphabet } from "nanoid" -import { parseAsStringEnum, useQueryState } from "nuqs" import { Button } from "@/components/ui/button" import { @@ -28,7 +27,7 @@ import { SelectValue, } from "@/components/ui/select" import { cn } from "@/lib/utils" -import { getFiltersStateParser } from "@/lib/parsers" +import { EVALUATION_TARGET_FILTER_OPTIONS } from "../validation" // nanoid 생성기 const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) @@ -43,56 +42,28 @@ const evaluationTargetFilterSchema = z.object({ consensusStatus: z.string().optional(), vendorCode: z.string().optional(), vendorName: z.string().optional(), - reviewerUserId: z.string().optional(), // 담당자 ID로 필터링 - orderReviewerName: z.string().optional(), // 주문 검토자명 - procurementReviewerName: z.string().optional(), // 조달 검토자명 - qualityReviewerName: z.string().optional(), // 품질 검토자명 - designReviewerName: z.string().optional(), // 설계 검토자명 - csReviewerName: z.string().optional(), // CS 검토자명 + reviewerUserId: z.string().optional(), + orderReviewerName: z.string().optional(), + procurementReviewerName: z.string().optional(), + qualityReviewerName: z.string().optional(), + designReviewerName: z.string().optional(), + csReviewerName: z.string().optional(), }) -// 옵션 정의 -const divisionOptions = [ - { value: "PLANT", label: "해양" }, - { value: "SHIP", label: "조선" }, -] - -const statusOptions = [ - { value: "PENDING", label: "검토 중" }, - { value: "CONFIRMED", label: "확정" }, - { value: "EXCLUDED", label: "제외" }, -] - -const domesticForeignOptions = [ - { value: "DOMESTIC", label: "내자" }, - { value: "FOREIGN", label: "외자" }, -] - -const materialTypeOptions = [ - { value: "EQUIPMENT", label: "기자재" }, - { value: "BULK", label: "벌크" }, - { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, -] - -const consensusStatusOptions = [ - { value: "true", label: "의견 일치" }, - { value: "false", label: "의견 불일치" }, - { value: "null", label: "검토 중" }, -] type EvaluationTargetFilterFormValues = z.infer<typeof evaluationTargetFilterSchema> interface EvaluationTargetFilterSheetProps { isOpen: boolean; onClose: () => void; - onSearch?: () => void; + onFiltersApply: (filters: any[], joinOperator: "and" | "or") => void; // ✅ 필터 전달 콜백 isLoading?: boolean; } export function EvaluationTargetFilterSheet({ isOpen, onClose, - onSearch, + onFiltersApply, isLoading = false }: EvaluationTargetFilterSheetProps) { const router = useRouter() @@ -100,25 +71,7 @@ export function EvaluationTargetFilterSheet({ const lng = params ? (params.lng as string) : 'ko'; const [isPending, startTransition] = useTransition() - - // 초기화 상태 추가 - const [isInitializing, setIsInitializing] = useState(false) - const lastAppliedFilters = useRef<string>("") - - // nuqs로 URL 상태 관리 - const [filters, setFilters] = useQueryState( - "basicFilters", - getFiltersStateParser().withDefault([]) - ) - - // joinOperator 설정 - const [joinOperator, setJoinOperator] = useQueryState( - "basicJoinOperator", - parseAsStringEnum(["and", "or"]).withDefault("and") - ) - - // 현재 URL의 페이지 파라미터도 가져옴 - const [page, setPage] = useQueryState("page", { defaultValue: "1" }) + const [joinOperator, setJoinOperator] = useState<"and" | "or">("and") // 폼 상태 초기화 const form = useForm<EvaluationTargetFilterFormValues>({ @@ -141,46 +94,13 @@ export function EvaluationTargetFilterSheet({ }, }) - // URL 필터에서 초기 폼 상태 설정 - useEffect(() => { - const currentFiltersString = JSON.stringify(filters); - - if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { - setIsInitializing(true); - - const formValues = { ...form.getValues() }; - let formUpdated = false; - - filters.forEach(filter => { - if (filter.id in formValues) { - // @ts-ignore - 동적 필드 접근 - formValues[filter.id] = filter.value; - formUpdated = true; - } - }); - - if (formUpdated) { - form.reset(formValues); - lastAppliedFilters.current = currentFiltersString; - } - - setIsInitializing(false); - } - }, [filters, isOpen]) - - // 현재 적용된 필터 카운트 - const getActiveFilterCount = () => { - return filters?.length || 0 - } - - // 폼 제출 핸들러 + // ✅ 폼 제출 핸들러 - 필터 배열 생성 및 전달 async function onSubmit(data: EvaluationTargetFilterFormValues) { - if (isInitializing) return; - startTransition(async () => { try { const newFilters = [] + // 필터 생성 로직 if (data.evaluationYear?.trim()) { newFilters.push({ id: "evaluationYear", @@ -271,7 +191,6 @@ export function EvaluationTargetFilterSheet({ }) } - // 새로 추가된 검토자명 필터들 if (data.orderReviewerName?.trim()) { newFilters.push({ id: "orderReviewerName", @@ -322,82 +241,41 @@ export function EvaluationTargetFilterSheet({ }) } - // URL 업데이트 - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - // 기존 필터 관련 파라미터 제거 - params.delete('basicFilters'); - params.delete('basicJoinOperator'); - params.delete('page'); - - // 새로운 필터 추가 - if (newFilters.length > 0) { - params.set('basicFilters', JSON.stringify(newFilters)); - params.set('basicJoinOperator', joinOperator); - } - - params.set('page', '1'); - - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - console.log("New Evaluation Target Filter URL:", newUrl); - - // 페이지 완전 새로고침 - window.location.href = newUrl; - - lastAppliedFilters.current = JSON.stringify(newFilters); - - if (onSearch) { - console.log("Calling evaluation target onSearch..."); - onSearch(); - } + console.log("=== 생성된 필터들 ===", newFilters); + console.log("=== 조인 연산자 ===", joinOperator); - console.log("=== Evaluation Target Filter Submit Complete ==="); + // ✅ 부모 컴포넌트에 필터 전달 + onFiltersApply(newFilters, joinOperator); + + console.log("=== 필터 적용 완료 ==="); } catch (error) { console.error("평가 대상 필터 적용 오류:", error); } }) } - // 필터 초기화 핸들러 - async function handleReset() { - try { - setIsInitializing(true); - - form.reset({ - evaluationYear: "", - division: "", - status: "", - domesticForeign: "", - materialType: "", - consensusStatus: "", - vendorCode: "", - vendorName: "", - reviewerUserId: "", - orderReviewerName: "", - procurementReviewerName: "", - qualityReviewerName: "", - designReviewerName: "", - csReviewerName: "", - }); - - // URL 초기화 - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - params.delete('basicFilters'); - params.delete('basicJoinOperator'); - params.set('page', '1'); - - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - window.location.href = newUrl; - - lastAppliedFilters.current = ""; - setIsInitializing(false); - } catch (error) { - console.error("평가 대상 필터 초기화 오류:", error); - setIsInitializing(false); - } + // ✅ 필터 초기화 핸들러 + function handleReset() { + form.reset({ + evaluationYear: "", + division: "", + status: "", + domesticForeign: "", + materialType: "", + consensusStatus: "", + vendorCode: "", + vendorName: "", + reviewerUserId: "", + orderReviewerName: "", + procurementReviewerName: "", + qualityReviewerName: "", + designReviewerName: "", + csReviewerName: "", + }); + + // 빈 필터 배열 전달 + onFiltersApply([], "and"); + setJoinOperator("and"); } if (!isOpen) { @@ -409,13 +287,14 @@ export function EvaluationTargetFilterSheet({ {/* Filter Panel Header */} <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> <h3 className="text-lg font-semibold whitespace-nowrap">평가 대상 검색 필터</h3> - <div className="flex items-center gap-2"> - {getActiveFilterCount() > 0 && ( - <Badge variant="secondary" className="px-2 py-1"> - {getActiveFilterCount()}개 필터 적용됨 - </Badge> - )} - </div> + <Button + variant="ghost" + size="icon" + onClick={onClose} + className="h-8 w-8" + > + <X className="size-4" /> + </Button> </div> {/* Join Operator Selection */} @@ -424,7 +303,6 @@ export function EvaluationTargetFilterSheet({ <Select value={joinOperator} onValueChange={(value: "and" | "or") => setJoinOperator(value)} - disabled={isInitializing} > <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> <SelectValue placeholder="조건 결합 방식" /> @@ -456,7 +334,6 @@ export function EvaluationTargetFilterSheet({ placeholder="평가년도 입력" {...field} className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} /> {field.value && ( <Button @@ -468,7 +345,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("evaluationYear", ""); }} - disabled={isInitializing} > <X className="size-3.5" /> </Button> @@ -490,7 +366,6 @@ export function EvaluationTargetFilterSheet({ <Select value={field.value} onValueChange={field.onChange} - disabled={isInitializing} > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> @@ -506,7 +381,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("division", ""); }} - disabled={isInitializing} > <X className="size-3" /> </Button> @@ -515,7 +389,7 @@ export function EvaluationTargetFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {divisionOptions.map(option => ( + {EVALUATION_TARGET_FILTER_OPTIONS.DIVISIONS.map(option => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> @@ -537,7 +411,6 @@ export function EvaluationTargetFilterSheet({ <Select value={field.value} onValueChange={field.onChange} - disabled={isInitializing} > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> @@ -553,7 +426,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("status", ""); }} - disabled={isInitializing} > <X className="size-3" /> </Button> @@ -562,7 +434,7 @@ export function EvaluationTargetFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {statusOptions.map(option => ( + {EVALUATION_TARGET_FILTER_OPTIONS.STATUSES.map(option => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> @@ -584,7 +456,6 @@ export function EvaluationTargetFilterSheet({ <Select value={field.value} onValueChange={field.onChange} - disabled={isInitializing} > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> @@ -600,7 +471,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("domesticForeign", ""); }} - disabled={isInitializing} > <X className="size-3" /> </Button> @@ -609,7 +479,7 @@ export function EvaluationTargetFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {domesticForeignOptions.map(option => ( + {EVALUATION_TARGET_FILTER_OPTIONS.DOMESTIC_FOREIGN.map(option => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> @@ -631,7 +501,6 @@ export function EvaluationTargetFilterSheet({ <Select value={field.value} onValueChange={field.onChange} - disabled={isInitializing} > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> @@ -647,7 +516,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("materialType", ""); }} - disabled={isInitializing} > <X className="size-3" /> </Button> @@ -656,7 +524,7 @@ export function EvaluationTargetFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {materialTypeOptions.map(option => ( + {EVALUATION_TARGET_FILTER_OPTIONS.MATERIAL_TYPES.map(option => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> @@ -678,7 +546,6 @@ export function EvaluationTargetFilterSheet({ <Select value={field.value} onValueChange={field.onChange} - disabled={isInitializing} > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> @@ -694,7 +561,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("consensusStatus", ""); }} - disabled={isInitializing} > <X className="size-3" /> </Button> @@ -703,7 +569,7 @@ export function EvaluationTargetFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {consensusStatusOptions.map(option => ( + {EVALUATION_TARGET_FILTER_OPTIONS.CONSENSUS_STATUS.map(option => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> @@ -728,7 +594,6 @@ export function EvaluationTargetFilterSheet({ placeholder="벤더 코드 입력" {...field} className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} /> {field.value && ( <Button @@ -740,7 +605,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("vendorCode", ""); }} - disabled={isInitializing} > <X className="size-3.5" /> </Button> @@ -765,7 +629,6 @@ export function EvaluationTargetFilterSheet({ placeholder="벤더명 입력" {...field} className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} /> {field.value && ( <Button @@ -777,7 +640,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("vendorName", ""); }} - disabled={isInitializing} > <X className="size-3.5" /> </Button> @@ -789,7 +651,43 @@ export function EvaluationTargetFilterSheet({ )} /> - {/* 주문 검토자명 */} + {/* 담당자 ID */} + <FormField + control={form.control} + name="reviewerUserId" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 ID</FormLabel> + <FormControl> + <div className="relative"> + <Input + type="number" + placeholder="담당자 ID 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("reviewerUserId", ""); + }} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 발주 담당자명 */} <FormField control={form.control} name="orderReviewerName" @@ -802,7 +700,6 @@ export function EvaluationTargetFilterSheet({ placeholder="발주 담당자명 입력" {...field} className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} /> {field.value && ( <Button @@ -814,7 +711,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("orderReviewerName", ""); }} - disabled={isInitializing} > <X className="size-3.5" /> </Button> @@ -826,7 +722,7 @@ export function EvaluationTargetFilterSheet({ )} /> - {/* 조달 검토자명 */} + {/* 조달 담당자명 */} <FormField control={form.control} name="procurementReviewerName" @@ -839,7 +735,6 @@ export function EvaluationTargetFilterSheet({ placeholder="조달 담당자명 입력" {...field} className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} /> {field.value && ( <Button @@ -851,7 +746,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("procurementReviewerName", ""); }} - disabled={isInitializing} > <X className="size-3.5" /> </Button> @@ -863,7 +757,7 @@ export function EvaluationTargetFilterSheet({ )} /> - {/* 품질 검토자명 */} + {/* 품질 담당자명 */} <FormField control={form.control} name="qualityReviewerName" @@ -876,7 +770,6 @@ export function EvaluationTargetFilterSheet({ placeholder="품질 담당자명 입력" {...field} className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} /> {field.value && ( <Button @@ -888,7 +781,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("qualityReviewerName", ""); }} - disabled={isInitializing} > <X className="size-3.5" /> </Button> @@ -900,7 +792,7 @@ export function EvaluationTargetFilterSheet({ )} /> - {/* 설계 검토자명 */} + {/* 설계 담당자명 */} <FormField control={form.control} name="designReviewerName" @@ -913,7 +805,6 @@ export function EvaluationTargetFilterSheet({ placeholder="설계 담당자명 입력" {...field} className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} /> {field.value && ( <Button @@ -925,7 +816,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("designReviewerName", ""); }} - disabled={isInitializing} > <X className="size-3.5" /> </Button> @@ -937,7 +827,7 @@ export function EvaluationTargetFilterSheet({ )} /> - {/* CS 검토자명 */} + {/* CS 담당자명 */} <FormField control={form.control} name="csReviewerName" @@ -950,7 +840,6 @@ export function EvaluationTargetFilterSheet({ placeholder="CS 담당자명 입력" {...field} className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} /> {field.value && ( <Button @@ -962,7 +851,6 @@ export function EvaluationTargetFilterSheet({ e.stopPropagation(); form.setValue("csReviewerName", ""); }} - disabled={isInitializing} > <X className="size-3.5" /> </Button> @@ -984,7 +872,7 @@ export function EvaluationTargetFilterSheet({ type="button" variant="outline" onClick={handleReset} - disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + disabled={isPending} className="px-4" > 초기화 @@ -992,7 +880,7 @@ export function EvaluationTargetFilterSheet({ <Button type="submit" variant="samsung" - disabled={isPending || isLoading || isInitializing} + disabled={isPending || isLoading} className="px-4" > <Search className="size-4 mr-2" /> diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx index d1c7e500..6a493d8e 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -10,7 +10,8 @@ import { Download, Upload, RefreshCw, - Settings + Settings, + Trash2 } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" @@ -30,6 +31,7 @@ import { ExcludeTargetsDialog, RequestReviewDialog } from "./evaluation-target-action-dialogs" +import { DeleteTargetsDialog } from "./delete-targets-dialog" import { EvaluationTargetWithDepartments } from "@/db/schema" import { exportTableToExcel } from "@/lib/export" import { autoGenerateEvaluationTargets } from "../service" // 서버 액션 import @@ -49,6 +51,7 @@ export function EvaluationTargetsTableToolbarActions({ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) const [excludeDialogOpen, setExcludeDialogOpen] = React.useState(false) const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) const router = useRouter() const { data: session } = useSession() @@ -137,7 +140,8 @@ export function EvaluationTargetsTableToolbarActions({ consensusNull, canConfirm: pending > 0 && consensusTrue > 0, canExclude: pending > 0, - canRequestReview: pending > 0 + canRequestReview: pending > 0, + canDelete: pending > 0 // 삭제는 PENDING 상태인 것만 가능 } }, [ pendingTargets.length, @@ -308,6 +312,22 @@ export function EvaluationTargetsTableToolbarActions({ </Button> )} + {/* 삭제 버튼 */} + {selectedStats.canDelete && ( + <Button + variant="destructive" + size="sm" + className="gap-2" + onClick={() => setDeleteDialogOpen(true)} + disabled={isLoading} + > + <Trash2 className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 삭제 ({selectedStats.pending}) + </span> + </Button> + )} + {/* 의견 요청 버튼 */} {selectedStats.canRequestReview && ( <Button @@ -369,6 +389,14 @@ export function EvaluationTargetsTableToolbarActions({ targets={selectedTargets} onSuccess={handleActionSuccess} /> + + {/* 삭제 컨펌 다이얼로그 */} + <DeleteTargetsDialog + open={deleteDialogOpen} + onOpenChange={setDeleteDialogOpen} + targets={pendingTargets} // PENDING 상태인 타겟들만 전달 + onSuccess={handleActionSuccess} + /> </> )} </> diff --git a/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx index af369ea6..44497cdb 100644 --- a/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx +++ b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx @@ -100,7 +100,7 @@ const createEvaluationTargetSchema = z.object({ evaluationYear: z.number().min(2020).max(2030), division: z.enum(["PLANT", "SHIP"]), vendorId: z.number().min(0), // 0도 허용, 클라이언트에서 검증 - materialType: z.enum(["EQUIPMENT", "BULK", "EQUIPMENT_BULK"]), + materialType: z.enum(["EQUIPMENT", "BULK"]), adminComment: z.string().optional(), // L/D 클레임 정보 ldClaimCount: z.number().min(0).optional(), diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx index 8ea63a1a..ef24aa9f 100644 --- a/lib/evaluation-target-list/table/update-evaluation-target.tsx +++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx @@ -503,8 +503,8 @@ export function EditEvaluationTargetSheet({ { key: "orderIsApproved", label: "발주 부서 평가", email: evaluationTarget.orderReviewerEmail }, { key: "procurementIsApproved", label: "조달 부서 평가", email: evaluationTarget.procurementReviewerEmail }, { key: "qualityIsApproved", label: "품질 부서 평가", email: evaluationTarget.qualityReviewerEmail }, - { key: "designIsApproved", label: "설계 부서 평가", email: evaluationTarget.designReviewerEmail }, - { key: "csIsApproved", label: "CS 부서 평가", email: evaluationTarget.csReviewerEmail }, + // { key: "designIsApproved", label: "설계 부서 평가", email: evaluationTarget.designReviewerEmail }, + // { key: "csIsApproved", label: "CS 부서 평가", email: evaluationTarget.csReviewerEmail }, ].map(({ key, label, email }) => ( <FormField key={key} diff --git a/lib/evaluation-target-list/validation.ts b/lib/evaluation-target-list/validation.ts index b8df250b..d37ca0ed 100644 --- a/lib/evaluation-target-list/validation.ts +++ b/lib/evaluation-target-list/validation.ts @@ -20,24 +20,15 @@ export const searchParamsEvaluationTargetsCache = createSearchParamsCache({ { id: "createdAt", desc: true }, ]), - // 기본 필터들 - evaluationYear: parseAsInteger.withDefault(new Date().getFullYear()), - division: parseAsString.withDefault(""), - status: parseAsString.withDefault(""), - domesticForeign: parseAsString.withDefault(""), - materialType: parseAsString.withDefault(""), - consensusStatus: parseAsString.withDefault(""), - - // 고급 필터 - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - // 베이직 필터 (커스텀 필터 패널용) - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + // 검색 search: parseAsString.withDefault(""), + aggregated: z.boolean().default(false), + }); // ============= 타입 정의 ============= @@ -46,7 +37,6 @@ export type GetEvaluationTargetsSchema = Awaited< ReturnType<typeof searchParamsEvaluationTargetsCache.parse> >; - // ============= 필터 옵션 상수들 ============= export const EVALUATION_TARGET_FILTER_OPTIONS = { @@ -64,9 +54,8 @@ export const EVALUATION_TARGET_FILTER_OPTIONS = { { value: "FOREIGN", label: "외자" }, ], MATERIAL_TYPES: [ - { value: "EQUIPMENT", label: "기자재" }, + { value: "EQUIPMENT", label: "장비재" }, { value: "BULK", label: "벌크" }, - { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, ], CONSENSUS_STATUS: [ { value: "true", label: "의견 일치" }, @@ -104,25 +93,6 @@ export function getDefaultEvaluationYear(): number { return new Date().getFullYear(); } -export function getDefaultSearchParams(): GetEvaluationTargetsSchema { - return { - flags: [], - page: 1, - perPage: 10, - sort: [{ id: "createdAt", desc: true }], - evaluationYear: getDefaultEvaluationYear(), - division: "", - status: "", - domesticForeign: "", - materialType: "", - consensusStatus: "", - filters: [], - joinOperator: "and", - basicFilters: [], - basicJoinOperator: "and", - search: "", - }; -} // ============= 편의 함수들 ============= @@ -151,3 +121,18 @@ export function getDomesticForeignLabel(domesticForeign: DomesticForeign): strin return domesticForeignMap[domesticForeign] || domesticForeign; } +// ✅ 디버깅용 로그 함수 추가 +export function logSearchParams(searchParams: any, source: string) { + console.log(`=== ${source} - SearchParams ===`); + console.log("Raw filters:", searchParams.filters); + console.log("Raw joinOperator:", searchParams.joinOperator); + + if (typeof searchParams.filters === 'string') { + try { + const parsed = JSON.parse(searchParams.filters); + console.log("Parsed filters:", parsed); + } catch (e) { + console.error("Filter parsing error:", e); + } + } +}
\ No newline at end of file diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts index c49521da..9889a110 100644 --- a/lib/evaluation/service.ts +++ b/lib/evaluation/service.ts @@ -6,6 +6,7 @@ import { evaluationTargetReviewers, evaluationTargets, periodicEvaluations, + periodicEvaluationsAggregatedView, periodicEvaluationsView, regEvalCriteria, regEvalCriteriaDetails, @@ -55,15 +56,6 @@ export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) }); } - // 2) 기본 필터 조건 - let basicWhere: SQL<unknown> | undefined = undefined; - if (input.basicFilters && input.basicFilters.length > 0) { - basicWhere = filterColumns({ - table: periodicEvaluationsView, - filters: input.basicFilters, - joinOperator: input.basicJoinOperator || 'and', - }); - } // 3) 글로벌 검색 조건 let globalWhere: SQL<unknown> | undefined = undefined; @@ -102,7 +94,6 @@ export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) const whereConditions: SQL<unknown>[] = []; if (advancedWhere) whereConditions.push(advancedWhere); - if (basicWhere) whereConditions.push(basicWhere); if (globalWhere) whereConditions.push(globalWhere); const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; @@ -157,7 +148,7 @@ export interface PeriodicEvaluationsStats { inReview: number reviewCompleted: number finalized: number - averageScore: number | null + // averageScore: number | null completionRate: number averageFinalScore: number | null documentsSubmittedCount: number @@ -179,7 +170,7 @@ export async function getPeriodicEvaluationsStats(evaluationYear: number): Promi const totalStatsResult = await db .select({ total: count(), - averageScore: avg(periodicEvaluationsView.totalScore), + // averageScore: avg(periodicEvaluationsView.totalScore), averageFinalScore: avg(periodicEvaluationsView.finalScore), }) .from(periodicEvaluationsView) @@ -187,7 +178,7 @@ export async function getPeriodicEvaluationsStats(evaluationYear: number): Promi const totalStats = totalStatsResult[0] || { total: 0, - averageScore: null, + // averageScore: null, averageFinalScore: null } @@ -265,7 +256,7 @@ export async function getPeriodicEvaluationsStats(evaluationYear: number): Promi inReview: statusCounts['IN_REVIEW'] || 0, reviewCompleted: statusCounts['REVIEW_COMPLETED'] || 0, finalized: finalizedCount, - averageScore: formatScore(totalStats.averageScore), + // averageScore: formatScore(totalStats.averageScore), averageFinalScore: formatScore(totalStats.averageFinalScore), completionRate, documentsSubmittedCount: documentCounts.submitted, @@ -288,7 +279,7 @@ export async function getPeriodicEvaluationsStats(evaluationYear: number): Promi inReview: 0, reviewCompleted: 0, finalized: 0, - averageScore: null, + // averageScore: null, averageFinalScore: null, completionRate: 0, documentsSubmittedCount: 0, @@ -569,6 +560,7 @@ export async function getReviewersForEvaluations( ) .orderBy(evaluationTargetReviewers.evaluationTargetId, users.name) + // 2. 추가: role name에 "정기평가"가 포함된 사용자들 const roleBasedReviewers = await db .select({ @@ -605,28 +597,11 @@ export async function getReviewersForEvaluations( } } - // 4. 중복 제거 (같은 사용자가 designated reviewer와 role-based reviewer 모두에 있을 수 있음) + // 4. 모든 리뷰어 합치기 (중복 제거 없이) const allReviewers = [...designatedReviewers, ...expandedRoleBasedReviewers] - // evaluationTargetId + userId 조합으로 중복 제거 - const uniqueReviewers = allReviewers.reduce((acc, reviewer) => { - const key = `${reviewer.evaluationTargetId}-${reviewer.id}` - - // 이미 있는 경우 designated reviewer를 우선 (evaluationTargetReviewerId가 양수인 것) - if (acc[key]) { - if (reviewer.evaluationTargetReviewerId > 0) { - acc[key] = reviewer // designated reviewer로 교체 - } - // 이미 designated reviewer가 있으면 role-based는 무시 - } else { - acc[key] = reviewer - } - - return acc - }, {} as Record<string, ReviewerInfo>) - - return Object.values(uniqueReviewers).sort((a, b) => { - // evaluationTargetId로 먼저 정렬, 그 다음 이름으로 정렬 + // 정렬만 수행 (evaluationTargetId로 먼저 정렬, 그 다음 이름으로 정렬) + return allReviewers.sort((a, b) => { if (a.evaluationTargetId !== b.evaluationTargetId) { return a.evaluationTargetId - b.evaluationTargetId } @@ -685,6 +660,8 @@ export async function createReviewerEvaluationsRequest( ) ) + console.log(newRequestData,"newRequestData") + if (newRequestData.length === 0) { throw new Error("모든 평가 요청이 이미 생성되어 있습니다.") } @@ -1320,4 +1297,171 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis : "평가 상세 정보 조회 중 오류가 발생했습니다" ) } +} + + +export async function getPeriodicEvaluationsAggregated(input: GetEvaluationTargetsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 1) 고급 필터 조건 (기존과 동일) + let advancedWhere: SQL<unknown> | undefined = undefined; + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: periodicEvaluationsAggregatedView, + filters: input.filters, + joinOperator: input.joinOperator || 'and', + }); + } + + // 2) 글로벌 검색 조건 (집계 뷰에 맞게 조정) + let globalWhere: SQL<unknown> | undefined = undefined; + if (input.search) { + const s = `%${input.search}%`; + + const validSearchConditions: SQL<unknown>[] = []; + + // 벤더 정보로 검색 + const vendorCodeCondition = ilike(periodicEvaluationsAggregatedView.vendorCode, s); + if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); + + const vendorNameCondition = ilike(periodicEvaluationsAggregatedView.vendorName, s); + if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); + + // 평가 관련 코멘트로 검색 + const evaluationNoteCondition = ilike(periodicEvaluationsAggregatedView.evaluationNote, s); + if (evaluationNoteCondition) validSearchConditions.push(evaluationNoteCondition); + + const adminCommentCondition = ilike(periodicEvaluationsAggregatedView.evaluationTargetAdminComment, s); + if (adminCommentCondition) validSearchConditions.push(adminCommentCondition); + + const consolidatedCommentCondition = ilike(periodicEvaluationsAggregatedView.evaluationTargetConsolidatedComment, s); + if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition); + + // 최종 확정자 이름으로 검색 + const finalizedByUserNameCondition = ilike(periodicEvaluationsAggregatedView.finalizedByUserName, s); + if (finalizedByUserNameCondition) validSearchConditions.push(finalizedByUserNameCondition); + + if (validSearchConditions.length > 0) { + globalWhere = or(...validSearchConditions); + } + } + + // 3) WHERE 조건 생성 + const whereConditions: SQL<unknown>[] = []; + + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); + + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + // 4) 전체 데이터 수 조회 + const totalResult = await db + .select({ count: count() }) + .from(periodicEvaluationsAggregatedView) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + if (total === 0) { + return { data: [], pageCount: 0, total: 0 }; + } + + console.log("Total aggregated periodic evaluations:", total); + + // 5) 정렬 및 페이징 처리된 데이터 조회 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof periodicEvaluationsAggregatedView.$inferSelect; + return sort.desc ? desc(periodicEvaluationsAggregatedView[column]) : asc(periodicEvaluationsAggregatedView[column]); + }); + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(periodicEvaluationsAggregatedView.createdAt)); + } + + const periodicEvaluationsData = await db + .select() + .from(periodicEvaluationsAggregatedView) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset); + + const pageCount = Math.ceil(total / input.perPage); + + return { data: periodicEvaluationsData, pageCount, total }; + + } catch (err) { + console.error("Error in getPeriodicEvaluationsAggregated:", err); + return { data: [], pageCount: 0, total: 0 }; + } +} + +// 기존 함수에 집계 옵션을 추가한 통합 함수 +export async function getPeriodicEvaluationsWithAggregation(input: GetEvaluationTargetsSchema) { + if (input.aggregated) { + return getPeriodicEvaluationsAggregated(input); + } else { + return getPeriodicEvaluations(input); + } +} + +// 집계된 주기평가 통계 함수 +export async function getPeriodicEvaluationsAggregatedStats(evaluationYear: number) { + try { + const statsQuery = await db + .select({ + total: count(), + pendingSubmission: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'PENDING_SUBMISSION' THEN 1 END)::int`, + submitted: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'SUBMITTED' THEN 1 END)::int`, + inReview: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'IN_REVIEW' THEN 1 END)::int`, + reviewCompleted: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'REVIEW_COMPLETED' THEN 1 END)::int`, + finalized: sql<number>`COUNT(CASE WHEN ${periodicEvaluationsAggregatedView.status} = 'FINALIZED' THEN 1 END)::int`, + averageScore: sql<number>`ROUND(AVG(NULLIF(${periodicEvaluationsAggregatedView.finalScore}, 0)), 1)`, + totalEvaluationCount: sql<number>`SUM(${periodicEvaluationsAggregatedView.evaluationCount})::int`, + }) + .from(periodicEvaluationsAggregatedView) + .where(eq(periodicEvaluationsAggregatedView.evaluationYear, evaluationYear)); + + const stats = statsQuery[0]; + + if (!stats) { + return { + total: 0, + pendingSubmission: 0, + submitted: 0, + inReview: 0, + reviewCompleted: 0, + finalized: 0, + completionRate: 0, + averageScore: 0, + totalEvaluationCount: 0, + }; + } + + const completionRate = stats.total > 0 + ? Math.round((stats.finalized / stats.total) * 100) + : 0; + + return { + ...stats, + completionRate, + }; + + } catch (error) { + console.error('Error fetching aggregated periodic evaluations stats:', error); + throw error; + } +} + +// 집계 모드에 따른 통계 조회 함수 +export async function getPeriodicEvaluationsStatsWithMode( + evaluationYear: number, + aggregated: boolean = false +) { + if (aggregated) { + return getPeriodicEvaluationsAggregatedStats(evaluationYear); + } else { + return getPeriodicEvaluationsStats(evaluationYear); + } }
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx index dca19ddb..e8b51b57 100644 --- a/lib/evaluation/table/evaluation-columns.tsx +++ b/lib/evaluation/table/evaluation-columns.tsx @@ -1,6 +1,4 @@ -// ================================================================ -// 1. PERIODIC EVALUATIONS COLUMNS -// ================================================================ +// components/evaluation/evaluation-columns.tsx - 집계 모드 지원 업데이트 "use client"; import * as React from "react"; @@ -8,33 +6,69 @@ import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis } from "lucide-react"; +import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis, BarChart3 } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; -import { PeriodicEvaluationView } from "@/db/schema"; +import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema"; import { DataTableRowAction } from "@/types/table"; import { vendortypeMap } from "@/types/evaluation"; - - interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PeriodicEvaluationView> | null>>; + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PeriodicEvaluationView | PeriodicEvaluationAggregatedView> | null>>; + viewMode?: "detailed" | "aggregated"; } -// 상태별 색상 매핑 +// 집계 모드용 division 배지 +const getDivisionBadgeWithAggregation = ( + division: string, + evaluationCount?: number, + divisions?: string +) => { + if (division === "BOTH") { + return ( + <div className="flex items-center gap-1"> + <Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200"> + 통합 + </Badge> + {evaluationCount && evaluationCount > 1 && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge variant="secondary" className="text-xs"> + {evaluationCount}개 + </Badge> + </TooltipTrigger> + <TooltipContent> + <p>{divisions?.replace(',', ', ')} 평가 통합</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + ); + } + + return ( + <Badge variant={division === "PLANT" ? "default" : "secondary"}> + {division === "PLANT" ? "해양" : "조선"} + </Badge> + ); +}; + +// 기존 함수들은 그대로 유지... const getStatusBadgeVariant = (status: string) => { switch (status) { - case "PENDING_SUBMISSION": - return "outline"; - case "SUBMITTED": - return "secondary"; - case "IN_REVIEW": - return "default"; - case "REVIEW_COMPLETED": - return "default"; - case "FINALIZED": - return "default"; - default: - return "outline"; + case "PENDING_SUBMISSION": return "outline"; + case "SUBMITTED": return "secondary"; + case "IN_REVIEW": return "default"; + case "REVIEW_COMPLETED": return "default"; + case "FINALIZED": return "default"; + default: return "outline"; } }; @@ -54,7 +88,6 @@ const getStatusLabel = (status: string) => { const getDepartmentStatusBadge = (status: string | null) => { if (!status) return ( <div className="flex items-center gap-1"> - {/* <Circle className="w-4 h-4 fill-gray-300 text-gray-300" /> */} <span className="text-xs text-gray-500">-</span> </div> ); @@ -63,7 +96,6 @@ const getDepartmentStatusBadge = (status: string | null) => { case "NOT_ASSIGNED": return ( <div className="flex items-center gap-1"> - {/* <Circle className="w-4 h-4 fill-gray-400 text-gray-400" /> */} <span className="text-xs text-gray-600">미지정</span> </div> ); @@ -71,73 +103,44 @@ const getDepartmentStatusBadge = (status: string | null) => { return ( <div className="flex items-center gap-1"> <div className="w-4 h-4 rounded-full bg-red-500 shadow-sm" /> - - {/* <span className="text-xs text-red-600">시작전</span> */} </div> ); case "IN_PROGRESS": return ( <div className="flex items-center gap-1"> <div className="w-4 h-4 rounded-full bg-yellow-500 shadow-sm" /> - {/* <span className="text-xs text-yellow-600">진행중</span> */} </div> ); case "COMPLETED": return ( <div className="flex items-center gap-1"> <div className="w-4 h-4 rounded-full bg-green-500 shadow-sm" /> - {/* <span className="text-xs text-green-600">완료</span> */} </div> ); default: return ( <div className="flex items-center gap-1"> - {/* <Circle className="w-4 h-4 fill-gray-300 text-gray-300" /> */} <span className="text-xs text-gray-500">-</span> </div> ); } }; -// 부서명 라벨 -const DEPARTMENT_LABELS = { - ORDER_EVAL: "발주", - PROCUREMENT_EVAL: "조달", - QUALITY_EVAL: "품질", - DESIGN_EVAL: "설계", - CS_EVAL: "CS" -} as const; // 등급별 색상 const getGradeBadgeVariant = (grade: string | null) => { if (!grade) return "outline"; switch (grade) { - case "S": - return "default"; - case "A": - return "secondary"; - case "B": - return "outline"; - case "C": - return "destructive"; - case "D": - return "destructive"; - default: - return "outline"; + case "S": return "default"; + case "A": return "secondary"; + case "B": return "outline"; + case "C": return "destructive"; + case "D": return "destructive"; + default: return "outline"; } }; -// 구분 배지 -const getDivisionBadge = (division: string) => { - return ( - <Badge variant={division === "PLANT" ? "default" : "secondary"}> - {division === "PLANT" ? "해양" : "조선"} - </Badge> - ); -}; - // 자재구분 배지 const getMaterialTypeBadge = (materialType: string) => { - return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>; }; @@ -150,18 +153,12 @@ const getDomesticForeignBadge = (domesticForeign: string) => { ); }; -// 진행률 배지 -const getProgressBadge = (completed: number, total: number) => { - if (total === 0) return <Badge variant="outline">-</Badge>; +export function getPeriodicEvaluationsColumns({ + setRowAction, + viewMode = "detailed" +}: GetColumnsProps): ColumnDef<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] { - const percentage = Math.round((completed / total) * 100); - const variant = percentage === 100 ? "default" : percentage >= 50 ? "secondary" : "destructive"; - - return <Badge variant={variant}>{completed}/{total} ({percentage}%)</Badge>; -}; - -export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ColumnDef<PeriodicEvaluationView>[] { - return [ + const baseColumns: ColumnDef<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] = [ // ═══════════════════════════════════════════════════════════════ // 선택 및 기본 정보 // ═══════════════════════════════════════════════════════════════ @@ -196,24 +193,37 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />, cell: ({ row }) => <span className="font-medium">{row.original.evaluationYear}</span>, size: 100, + meta: { + excelHeader: "평가년도", + }, }, - // ░░░ 평가기간 ░░░ - // { - // accessorKey: "evaluationPeriod", - // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가기간" />, - // cell: ({ row }) => ( - // <Badge variant="outline">{row.getValue("evaluationPeriod")}</Badge> - // ), - // size: 100, - // }, - - // ░░░ 구분 ░░░ + // ░░░ 구분 ░░░ - 집계 모드에 따라 다르게 렌더링 { accessorKey: "division", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />, - cell: ({ row }) => getDivisionBadge(row.original.division || ""), - size: 80, + cell: ({ row }) => { + const division =viewMode === "aggregated"?"BOTH": row.original.division || ""; + + if (viewMode === "aggregated") { + const aggregatedRow = row.original as PeriodicEvaluationAggregatedView; + return getDivisionBadgeWithAggregation( + division, + aggregatedRow.evaluationCount, + aggregatedRow.divisions + ); + } + + return ( + <Badge variant={division === "PLANT" ? "default" : "secondary"}> + {division === "PLANT" ? "해양" : "조선"} + </Badge> + ); + }, + size: viewMode === "aggregated" ? 120 : 80, + meta: { + excelHeader: "구분", + }, }, { @@ -221,9 +231,11 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Status" />, cell: ({ row }) => getStatusLabel(row.original.status || ""), size: 80, + meta: { + excelHeader: "Status", + }, }, - // ═══════════════════════════════════════════════════════════════ // 협력업체 정보 // ═══════════════════════════════════════════════════════════════ @@ -237,6 +249,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): <span className="font-mono text-sm">{row.original.vendorCode}</span> ), size: 120, + meta: { + excelHeader: "벤더 코드", + }, }, { @@ -248,6 +263,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): </div> ), size: 200, + meta: { + excelHeader: "벤더명", + }, }, { @@ -255,6 +273,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />, cell: ({ row }) => getDomesticForeignBadge(row.original.domesticForeign || ""), size: 80, + meta: { + excelHeader: "내외자", + }, }, { @@ -262,22 +283,87 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />, cell: ({ row }) => getMaterialTypeBadge(row.original.materialType || ""), size: 120, + meta: { + excelHeader: "자재구분", + }, }, ] }, + + // 집계 모드에서만 보이는 평가 개수 컬럼 + ...(viewMode === "aggregated" ? [{ + accessorKey: "evaluationCount", + header: ({ column }) => ( + <div className="flex items-center gap-1"> + <BarChart3 className="h-4 w-4" /> + <DataTableColumnHeaderSimple column={column} title="평가수" /> + </div> + ), + cell: ({ row }) => { + const aggregatedRow = row.original as PeriodicEvaluationAggregatedView; + const count = aggregatedRow.evaluationCount || 1; + + return ( + <div className="flex items-center gap-1"> + <Badge variant={count > 1 ? "default" : "outline"} className="font-mono"> + {count}개 + </Badge> + {count > 1 && aggregatedRow.divisions && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <div className="text-xs text-muted-foreground cursor-help"> + ({aggregatedRow.divisions.replace(',', ', ')}) + </div> + </TooltipTrigger> + <TooltipContent> + <p>{aggregatedRow.divisions.replace(',', ', ')} 평가의 평균값</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + ); + }, + size: 100, + meta: { + excelHeader: "평가수", + }, + }] : []), { accessorKey: "finalScore", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정점수" />, cell: ({ row }) => { const finalScore = row.getValue<number>("finalScore"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return finalScore ? ( - <span className="font-bold text-green-600">{Number(finalScore).toFixed(1)}</span> + <div className="flex items-center gap-1"> + <span className={`font-bold ${isAggregated ? 'text-purple-600' : 'text-green-600'}`}> + {Number(finalScore).toFixed(1)} + </span> + {isAggregated && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge variant="outline" className="text-xs bg-purple-50">평균</Badge> + </TooltipTrigger> + <TooltipContent> + <p>여러 평가의 평균값</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> ) : ( <span className="text-muted-foreground">-</span> ); }, - size: 90, + size: viewMode === "aggregated" ? 120 : 90, + meta: { + excelHeader: "확정점수", + }, }, { @@ -285,8 +371,13 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정등급" />, cell: ({ row }) => { const finalGrade = row.getValue<string>("finalGrade"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return finalGrade ? ( - <Badge variant={getGradeBadgeVariant(finalGrade)} className="bg-green-600"> + <Badge + variant={getGradeBadgeVariant(finalGrade)} + className={isAggregated ? "bg-purple-600" : "bg-green-600"} + > {finalGrade} </Badge> ) : ( @@ -294,10 +385,13 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ); }, size: 90, + meta: { + excelHeader: "확정등급", + }, }, - // ═══════════════════════════════════════════════════════════════ - // 진행 현황 + // ═══════════════════════════════════════════════════════════════ + // 진행 현황 - 집계 모드에서는 최고 진행 상태를 보여줌 // ═══════════════════════════════════════════════════════════════ { header: "부서별 평가 현황", @@ -307,6 +401,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="발주" />, cell: ({ row }) => getDepartmentStatusBadge(row.getValue("orderEvalStatus")), size: 60, + meta: { + excelHeader: "발주", + }, }, { @@ -314,6 +411,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="조달" />, cell: ({ row }) => getDepartmentStatusBadge(row.getValue("procurementEvalStatus")), size: 70, + meta: { + excelHeader: "조달", + }, }, { @@ -321,6 +421,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품질" />, cell: ({ row }) => getDepartmentStatusBadge(row.getValue("qualityEvalStatus")), size: 70, + meta: { + excelHeader: "품질", + }, }, { @@ -328,6 +431,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계" />, cell: ({ row }) => getDepartmentStatusBadge(row.getValue("designEvalStatus")), size: 70, + meta: { + excelHeader: "설계", + }, }, { @@ -335,13 +441,24 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="CS" />, cell: ({ row }) => getDepartmentStatusBadge(row.getValue("csEvalStatus")), size: 70, + meta: { + excelHeader: "CS", + }, + }, + + { + accessorKey: "adminEvalStatus", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="관리자" />, + cell: ({ row }) => getDepartmentStatusBadge(row.getValue("adminEvalStatus")), + size: 120, + meta: { + excelHeader: "관리자", + }, }, ] }, { - // id: "평가상세", - // accessorKey: "평가상세", header: "평가상세", enableHiding: true, size: 80, @@ -359,11 +476,11 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): > <Ellipsis className="size-4" /> </Button> - </div> ); }, }, + // ═══════════════════════════════════════════════════════════════ // 제출 현황 // ═══════════════════════════════════════════════════════════════ @@ -375,13 +492,32 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="문서제출" />, cell: ({ row }) => { const submitted = row.getValue<boolean>("documentsSubmitted"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return ( - <Badge variant={submitted ? "default" : "destructive"}> - {submitted ? "제출완료" : "미제출"} - </Badge> + <div className="flex items-center gap-1"> + <Badge variant={submitted ? "default" : "destructive"}> + {submitted ? "제출완료" : "미제출"} + </Badge> + {isAggregated && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge variant="outline" className="text-xs">통합</Badge> + </TooltipTrigger> + <TooltipContent> + <p>모든 평가에서 제출 완료된 경우만 "제출완료"</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> ); }, - size: 120, + size: viewMode === "aggregated" ? 140 : 120, + meta: { + excelHeader: "문서제출", + }, }, { @@ -389,18 +525,37 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="제출일" />, cell: ({ row }) => { const submissionDate = row.getValue<Date>("submissionDate"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return submissionDate ? ( - <span className="text-sm"> - {new Intl.DateTimeFormat("ko-KR", { - month: "2-digit", - day: "2-digit", - }).format(new Date(submissionDate))} - </span> + <div className="flex items-center gap-1"> + <span className="text-sm"> + {new Intl.DateTimeFormat("ko-KR", { + month: "2-digit", + day: "2-digit", + }).format(new Date(submissionDate))} + </span> + {isAggregated && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge variant="outline" className="text-xs">최신</Badge> + </TooltipTrigger> + <TooltipContent> + <p>가장 최근 제출일</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> ) : ( <span className="text-muted-foreground">-</span> ); }, - size: 80, + size: viewMode === "aggregated" ? 110 : 80, + meta: { + excelHeader: "제출일", + }, }, { @@ -423,30 +578,38 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ); }, size: 80, + meta: { + excelHeader: "마감일", + }, }, ] }, - - // ═══════════════════════════════════════════════════════════════ - // 평가 점수 + // 평가 점수 - 집계 모드에서는 평균임을 명시 // ═══════════════════════════════════════════════════════════════ { - header: "평가 점수", + header: viewMode === "aggregated" ? "평가 점수 (평균)" : "평가 점수", columns: [ { accessorKey: "processScore", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="공정" />, cell: ({ row }) => { const score = row.getValue("processScore"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return score ? ( - <span className="font-medium">{Number(score).toFixed(1)}</span> + <span className={`font-medium ${isAggregated ? 'text-purple-600' : ''}`}> + {Number(score).toFixed(1)} + </span> ) : ( <span className="text-muted-foreground">-</span> ); }, size: 80, + meta: { + excelHeader: "공정", + }, }, { @@ -454,13 +617,20 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="가격" />, cell: ({ row }) => { const score = row.getValue("priceScore"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return score ? ( - <span className="font-medium">{Number(score).toFixed(1)}</span> + <span className={`font-medium ${isAggregated ? 'text-purple-600' : ''}`}> + {Number(score).toFixed(1)} + </span> ) : ( <span className="text-muted-foreground">-</span> ); }, size: 80, + meta: { + excelHeader: "가격", + }, }, { @@ -468,13 +638,20 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="납기" />, cell: ({ row }) => { const score = row.getValue("deliveryScore"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return score ? ( - <span className="font-medium">{Number(score).toFixed(1)}</span> + <span className={`font-medium ${isAggregated ? 'text-purple-600' : ''}`}> + {Number(score).toFixed(1)} + </span> ) : ( <span className="text-muted-foreground">-</span> ); }, size: 80, + meta: { + excelHeader: "납기", + }, }, { @@ -482,13 +659,20 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자율평가" />, cell: ({ row }) => { const score = row.getValue("selfEvaluationScore"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return score ? ( - <span className="font-medium">{Number(score).toFixed(1)}</span> + <span className={`font-medium ${isAggregated ? 'text-purple-600' : ''}`}> + {Number(score).toFixed(1)} + </span> ) : ( <span className="text-muted-foreground">-</span> ); }, size: 80, + meta: { + excelHeader: "자율평가", + }, }, // ✅ 합계 - 4개 점수의 합으로 계산 @@ -502,9 +686,12 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): const selfEvaluationScore = Number(row.getValue("selfEvaluationScore") || 0); const total = processScore + priceScore + deliveryScore + selfEvaluationScore; + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; return total > 0 ? ( - <span className="font-medium bg-blue-50 px-2 py-1 rounded"> + <span className={`font-medium px-2 py-1 rounded ${ + isAggregated ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600' + }`}> {total.toFixed(1)} </span> ) : ( @@ -512,6 +699,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ); }, size: 80, + meta: { + excelHeader: "합계", + }, }, { @@ -519,13 +709,20 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여도(가점)" />, cell: ({ row }) => { const score = row.getValue("participationBonus"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return score ? ( - <span className="font-medium text-green-600">+{Number(score).toFixed(1)}</span> + <span className={`font-medium ${isAggregated ? 'text-purple-600' : 'text-green-600'}`}> + +{Number(score).toFixed(1)} + </span> ) : ( <span className="text-muted-foreground">-</span> ); }, size: 100, + meta: { + excelHeader: "참여도(가점)", + }, }, { @@ -533,16 +730,23 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품질(감점)" />, cell: ({ row }) => { const score = row.getValue("qualityDeduction"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return score ? ( - <span className="font-medium text-red-600">-{Number(score).toFixed(1)}</span> + <span className={`font-medium ${isAggregated ? 'text-purple-600' : 'text-red-600'}`}> + -{Number(score).toFixed(1)} + </span> ) : ( <span className="text-muted-foreground">-</span> ); }, size: 100, + meta: { + excelHeader: "품질(감점)", + }, }, - // ✅ 새로운 평가점수 컬럼 추가 + // ✅ 평가점수 컬럼 { id: "evaluationScore", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가점수" />, @@ -556,9 +760,12 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): const totalScore = processScore + priceScore + deliveryScore + selfEvaluationScore; const evaluationScore = totalScore + participationBonus - qualityDeduction; + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; return totalScore > 0 ? ( - <span className="font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded"> + <span className={`font-bold px-2 py-1 rounded ${ + isAggregated ? 'bg-purple-50 text-purple-600' : 'bg-blue-50 text-blue-600' + }`}> {evaluationScore.toFixed(1)} </span> ) : ( @@ -566,6 +773,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ); }, size: 90, + meta: { + excelHeader: "평가점수", + }, }, { @@ -573,16 +783,27 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가등급" />, cell: ({ row }) => { const grade = row.getValue<string>("evaluationGrade"); + const isAggregated = viewMode === "aggregated" && (row.original as PeriodicEvaluationAggregatedView).evaluationCount > 1; + return grade ? ( - <Badge variant={getGradeBadgeVariant(grade)}>{grade}</Badge> + <Badge + variant={getGradeBadgeVariant(grade)} + className={isAggregated ? "bg-purple-600" : ""} + > + {grade} + </Badge> ) : ( <span className="text-muted-foreground">-</span> ); }, minSize: 100, + meta: { + excelHeader: "평가등급", + }, }, - ] }, ]; + + return baseColumns; }
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx index 7f4de6a6..8f435e36 100644 --- a/lib/evaluation/table/evaluation-filter-sheet.tsx +++ b/lib/evaluation/table/evaluation-filter-sheet.tsx @@ -1,19 +1,15 @@ -// ================================================================ -// 2. PERIODIC EVALUATIONS FILTER SHEET -// ================================================================ - -"use client" - -import { useEffect, useTransition, useState, useRef } from "react" -import { useRouter, useParams } from "next/navigation" -import { z } from "zod" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Search, X } from "lucide-react" -import { customAlphabet } from "nanoid" -import { parseAsStringEnum, useQueryState } from "nuqs" - -import { Button } from "@/components/ui/button" +"use client"; + +import { useEffect, useTransition, useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Search, X } from "lucide-react"; +import { customAlphabet } from "nanoid"; +import { parseAsStringEnum, useQueryState } from "nuqs"; + +import { Button } from "@/components/ui/button"; import { Form, FormControl, @@ -21,50 +17,28 @@ import { FormItem, FormLabel, FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { cn } from "@/lib/utils" -import { getFiltersStateParser } from "@/lib/parsers" +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { getFiltersStateParser } from "@/lib/parsers"; +import { EVALUATION_TARGET_FILTER_OPTIONS } from "@/lib/evaluation-target-list/validation"; -// nanoid 생성기 -const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) +/***************************************************************************************** + * UTILS & CONSTANTS + *****************************************************************************************/ -// 정기평가 필터 스키마 정의 -const periodicEvaluationFilterSchema = z.object({ - evaluationYear: z.string().optional(), - evaluationPeriod: z.string().optional(), - division: z.string().optional(), - status: z.string().optional(), - domesticForeign: z.string().optional(), - materialType: z.string().optional(), - vendorCode: z.string().optional(), - vendorName: z.string().optional(), - documentsSubmitted: z.string().optional(), - evaluationGrade: z.string().optional(), - finalGrade: z.string().optional(), - minTotalScore: z.string().optional(), - maxTotalScore: z.string().optional(), -}) +// nanoid generator (6‑chars [0-9a-zA-Z]) +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6); -// 옵션 정의 -const evaluationPeriodOptions = [ - { value: "상반기", label: "상반기" }, - { value: "하반기", label: "하반기" }, - { value: "연간", label: "연간" }, -] - -const divisionOptions = [ - { value: "PLANT", label: "해양" }, - { value: "SHIP", label: "조선" }, -] +// ── SELECT OPTIONS ────────────────────────────────────────────────────────────────────── const statusOptions = [ { value: "PENDING", label: "대상확정" }, @@ -73,70 +47,76 @@ const statusOptions = [ { value: "IN_REVIEW", label: "평가중" }, { value: "REVIEW_COMPLETED", label: "평가완료" }, { value: "FINALIZED", label: "결과확정" }, -] - -const domesticForeignOptions = [ - { value: "DOMESTIC", label: "내자" }, - { value: "FOREIGN", label: "외자" }, -] +]; -const materialTypeOptions = [ - { value: "EQUIPMENT", label: "기자재" }, - { value: "BULK", label: "벌크" }, - { value: "EQUIPMENT_BULK", label: "기자재/벌크" }, -] const documentsSubmittedOptions = [ { value: "true", label: "제출완료" }, { value: "false", label: "미제출" }, -] +]; const gradeOptions = [ { value: "A", label: "A등급" }, { value: "B", label: "B등급" }, { value: "C", label: "C등급" }, { value: "D", label: "D등급" }, -] +]; -type PeriodicEvaluationFilterFormValues = z.infer<typeof periodicEvaluationFilterSchema> +/***************************************************************************************** + * ZOD SCHEMA & TYPES + *****************************************************************************************/ +const periodicEvaluationFilterSchema = z.object({ + evaluationYear: z.string().optional(), + division: z.string().optional(), + status: z.string().optional(), + domesticForeign: z.string().optional(), + materialType: z.string().optional(), + vendorCode: z.string().optional(), + vendorName: z.string().optional(), + documentsSubmitted: z.string().optional(), + evaluationGrade: z.string().optional(), + finalGrade: z.string().optional(), + minTotalScore: z.string().optional(), + maxTotalScore: z.string().optional(), +}); +export type PeriodicEvaluationFilterFormValues = z.infer< + typeof periodicEvaluationFilterSchema +>; + +/***************************************************************************************** + * COMPONENT + *****************************************************************************************/ interface PeriodicEvaluationFilterSheetProps { + /** Slide‑over visibility */ isOpen: boolean; + /** Close panel handler */ onClose: () => void; - onSearch?: () => void; + /** Show skeleton / spinner while outer data grid fetches */ isLoading?: boolean; + /** Optional: fire immediately after URL is patched so parent can refetch */ + onFiltersApply: (filters: any[], joinOperator: "and" | "or") => void; // ✅ 필터 전달 콜백 } export function PeriodicEvaluationFilterSheet({ isOpen, onClose, - onSearch, - isLoading = false + isLoading = false, + onFiltersApply, }: PeriodicEvaluationFilterSheetProps) { - const router = useRouter() - const params = useParams(); - - const [isPending, startTransition] = useTransition() - const [isInitializing, setIsInitializing] = useState(false) - const lastAppliedFilters = useRef<string>("") + /** Router (needed only for pathname) */ + const router = useRouter(); - // nuqs로 URL 상태 관리 - const [filters, setFilters] = useQueryState( - "basicFilters", - getFiltersStateParser().withDefault([]) - ) + /** Track pending state while we update URL */ + const [isPending, startTransition] = useTransition(); + const [joinOperator, setJoinOperator] = useState<"and" | "or">("and") - const [joinOperator, setJoinOperator] = useQueryState( - "basicJoinOperator", - parseAsStringEnum(["and", "or"]).withDefault("and") - ) - // 폼 상태 초기화 + /** React‑Hook‑Form */ const form = useForm<PeriodicEvaluationFilterFormValues>({ resolver: zodResolver(periodicEvaluationFilterSchema), defaultValues: { evaluationYear: new Date().getFullYear().toString(), - evaluationPeriod: "", division: "", status: "", domesticForeign: "", @@ -149,273 +129,130 @@ export function PeriodicEvaluationFilterSheet({ minTotalScore: "", maxTotalScore: "", }, - }) - - // URL 필터에서 초기 폼 상태 설정 - useEffect(() => { - const currentFiltersString = JSON.stringify(filters); - - if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { - setIsInitializing(true); - - const formValues = { ...form.getValues() }; - let formUpdated = false; - - filters.forEach(filter => { - if (filter.id in formValues) { - // @ts-ignore - 동적 필드 접근 - formValues[filter.id] = filter.value; - formUpdated = true; - } - }); - - if (formUpdated) { - form.reset(formValues); - lastAppliedFilters.current = currentFiltersString; - } - - setIsInitializing(false); - } - }, [filters, isOpen]) + }); - // 현재 적용된 필터 카운트 - const getActiveFilterCount = () => { - return filters?.length || 0 - } - // 폼 제출 핸들러 + /***************************************************************************************** + * 3️⃣ Submit → build filter array → push to URL (and reset page=1) + *****************************************************************************************/ async function onSubmit(data: PeriodicEvaluationFilterFormValues) { - if (isInitializing) return; - - startTransition(async () => { + startTransition(() => { try { - const newFilters = [] + const newFilters: any[] = []; - if (data.evaluationYear?.trim()) { - newFilters.push({ - id: "evaluationYear", - value: parseInt(data.evaluationYear.trim()), - type: "number", - operator: "eq", - rowId: generateId() - }) - } + const pushFilter = ( + id: string, + value: any, + type: "text" | "select" | "number" | "boolean", + operator: "eq" | "iLike" | "gte" | "lte" + ) => { + newFilters.push({ id, value, type, operator, rowId: generateId() }); + }; - if (data.evaluationPeriod?.trim()) { - newFilters.push({ - id: "evaluationPeriod", - value: data.evaluationPeriod.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } + if (data.evaluationYear?.trim()) + pushFilter("evaluationYear", Number(data.evaluationYear), "number", "eq"); - if (data.division?.trim()) { - newFilters.push({ - id: "division", - value: data.division.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } + if (data.division?.trim()) + pushFilter("division", data.division.trim(), "select", "eq"); - if (data.status?.trim()) { - newFilters.push({ - id: "status", - value: data.status.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } + if (data.status?.trim()) + pushFilter("status", data.status.trim(), "select", "eq"); - if (data.domesticForeign?.trim()) { - newFilters.push({ - id: "domesticForeign", - value: data.domesticForeign.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } + if (data.domesticForeign?.trim()) + pushFilter("domesticForeign", data.domesticForeign.trim(), "select", "eq"); - if (data.materialType?.trim()) { - newFilters.push({ - id: "materialType", - value: data.materialType.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } + if (data.materialType?.trim()) + pushFilter("materialType", data.materialType.trim(), "select", "eq"); - if (data.vendorCode?.trim()) { - newFilters.push({ - id: "vendorCode", - value: data.vendorCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } + if (data.vendorCode?.trim()) + pushFilter("vendorCode", data.vendorCode.trim(), "text", "iLike"); - if (data.vendorName?.trim()) { - newFilters.push({ - id: "vendorName", - value: data.vendorName.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } + if (data.vendorName?.trim()) + pushFilter("vendorName", data.vendorName.trim(), "text", "iLike"); - if (data.documentsSubmitted?.trim()) { - newFilters.push({ - id: "documentsSubmitted", - value: data.documentsSubmitted.trim() === "true", - type: "boolean", - operator: "eq", - rowId: generateId() - }) - } + if (data.documentsSubmitted?.trim()) + pushFilter( + "documentsSubmitted", + data.documentsSubmitted.trim() === "true", + "boolean", + "eq" + ); - if (data.evaluationGrade?.trim()) { - newFilters.push({ - id: "evaluationGrade", - value: data.evaluationGrade.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } + if (data.evaluationGrade?.trim()) + pushFilter("evaluationGrade", data.evaluationGrade.trim(), "select", "eq"); - if (data.finalGrade?.trim()) { - newFilters.push({ - id: "finalGrade", - value: data.finalGrade.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } + if (data.finalGrade?.trim()) + pushFilter("finalGrade", data.finalGrade.trim(), "select", "eq"); - if (data.minTotalScore?.trim()) { - newFilters.push({ - id: "totalScore", - value: parseFloat(data.minTotalScore.trim()), - type: "number", - operator: "gte", - rowId: generateId() - }) - } + if (data.minTotalScore?.trim()) + pushFilter("totalScore", Number(data.minTotalScore), "number", "gte"); - if (data.maxTotalScore?.trim()) { - newFilters.push({ - id: "totalScore", - value: parseFloat(data.maxTotalScore.trim()), - type: "number", - operator: "lte", - rowId: generateId() - }) - } + if (data.maxTotalScore?.trim()) + pushFilter("totalScore", Number(data.maxTotalScore), "number", "lte"); - // URL 업데이트 - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - params.delete('basicFilters'); - params.delete('basicJoinOperator'); - params.delete('page'); - - if (newFilters.length > 0) { - params.set('basicFilters', JSON.stringify(newFilters)); - params.set('basicJoinOperator', joinOperator); - } - - params.set('page', '1'); - - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - window.location.href = newUrl; + setJoinOperator(joinOperator); - lastAppliedFilters.current = JSON.stringify(newFilters); - if (onSearch) { - onSearch(); - } - } catch (error) { - console.error("정기평가 필터 적용 오류:", error); + onFiltersApply(newFilters, joinOperator); + } catch (err) { + // eslint-disable-next-line no-console + console.error("정기평가 필터 적용 오류:", err); } - }) + }); } - // 필터 초기화 핸들러 + /***************************************************************************************** + * 4️⃣ Reset → clear form & URL + *****************************************************************************************/ async function handleReset() { - try { - setIsInitializing(true); - - form.reset({ - evaluationYear: new Date().getFullYear().toString(), - evaluationPeriod: "", - division: "", - status: "", - domesticForeign: "", - materialType: "", - vendorCode: "", - vendorName: "", - documentsSubmitted: "", - evaluationGrade: "", - finalGrade: "", - minTotalScore: "", - maxTotalScore: "", - }); - - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - params.delete('basicFilters'); - params.delete('basicJoinOperator'); - params.set('page', '1'); + form.reset({ + evaluationYear: new Date().getFullYear().toString(), + division: "", + status: "", + domesticForeign: "", + materialType: "", + vendorCode: "", + vendorName: "", + documentsSubmitted: "", + evaluationGrade: "", + finalGrade: "", + minTotalScore: "", + maxTotalScore: "", + }); - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - window.location.href = newUrl; + onFiltersApply([], "and"); + setJoinOperator("and"); - lastAppliedFilters.current = ""; - setIsInitializing(false); - } catch (error) { - console.error("정기평가 필터 초기화 오류:", error); - setIsInitializing(false); - } } - if (!isOpen) { - return null; - } + /***************************************************************************************** + * 5️⃣ RENDER + *****************************************************************************************/ + if (!isOpen) return null; return ( - <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> - {/* Filter Panel Header */} - <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> - <h3 className="text-lg font-semibold whitespace-nowrap">정기평가 검색 필터</h3> + <div + className="flex h-full max-h-full flex-col px-6 sm:px-8" + style={{ backgroundColor: "#F5F7FB", paddingLeft: "2rem", paddingRight: "2rem" }} + > + {/* Header */} + <div className="flex shrink-0 min-h-[60px] items-center justify-between px-6"> + <h3 className="whitespace-nowrap text-lg font-semibold">정기평가 검색 필터</h3> <div className="flex items-center gap-2"> - {getActiveFilterCount() > 0 && ( - <Badge variant="secondary" className="px-2 py-1"> - {getActiveFilterCount()}개 필터 적용됨 - </Badge> - )} + <Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8"> + <X className="size-4" /> + </Button> </div> </div> - {/* Join Operator Selection */} - <div className="px-6 shrink-0"> + {/* Join‑operator selector */} + <div className="shrink-0 px-6"> <label className="text-sm font-medium">조건 결합 방식</label> <Select value={joinOperator} - onValueChange={(value: "and" | "or") => setJoinOperator(value)} - disabled={isInitializing} + onValueChange={(v: "and" | "or") => setJoinOperator(v)} > - <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectTrigger className="mt-2 h-8 w-[180px] bg-white"> <SelectValue placeholder="조건 결합 방식" /> </SelectTrigger> <SelectContent> @@ -425,12 +262,12 @@ export function PeriodicEvaluationFilterSheet({ </Select> </div> + {/* Form */} <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> - {/* Scrollable content area */} + <form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-col h-full"> + {/* Scrollable area */} <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> <div className="space-y-4 pt-2"> - {/* 평가년도 */} <FormField control={form.control} @@ -444,20 +281,20 @@ export function PeriodicEvaluationFilterSheet({ type="number" placeholder="평가년도 입력" {...field} - className={cn(field.value && "pr-8", "bg-white")} disabled={isInitializing} + className={cn(field.value && "pr-8", "bg-white")} /> {field.value && ( <Button type="button" variant="ghost" size="icon" - className="absolute right-0 top-0 h-full px-2" onClick={(e) => { e.stopPropagation(); form.setValue("evaluationYear", ""); }} disabled={isInitializing} + className="absolute right-0 top-0 h-full px-2" > <X className="size-3.5" /> </Button> @@ -469,52 +306,6 @@ export function PeriodicEvaluationFilterSheet({ )} /> - {/* 평가기간 */} - {/* <FormField - control={form.control} - name="evaluationPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel>평가기간</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - disabled={isInitializing} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder="평가기간 선택" /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("evaluationPeriod", ""); - }} - disabled={isInitializing} - > - <X className="size-3" /> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - {evaluationPeriodOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> */} {/* 구분 */} <FormField @@ -530,14 +321,14 @@ export function PeriodicEvaluationFilterSheet({ > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> + <div className="flex w-full justify-between"> <SelectValue placeholder="구분 선택" /> {field.value && ( <Button type="button" variant="ghost" size="icon" - className="h-4 w-4 -mr-2" + className="-mr-2 h-4 w-4" onClick={(e) => { e.stopPropagation(); form.setValue("division", ""); @@ -551,9 +342,9 @@ export function PeriodicEvaluationFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {divisionOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} + {EVALUATION_TARGET_FILTER_OPTIONS.DIVISIONS.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} </SelectItem> ))} </SelectContent> @@ -577,14 +368,14 @@ export function PeriodicEvaluationFilterSheet({ > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> + <div className="flex w-full justify-between"> <SelectValue placeholder="진행상태 선택" /> {field.value && ( <Button type="button" variant="ghost" size="icon" - className="h-4 w-4 -mr-2" + className="-mr-2 h-4 w-4" onClick={(e) => { e.stopPropagation(); form.setValue("status", ""); @@ -598,9 +389,9 @@ export function PeriodicEvaluationFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {statusOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} + {statusOptions.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} </SelectItem> ))} </SelectContent> @@ -624,14 +415,14 @@ export function PeriodicEvaluationFilterSheet({ > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> + <div className="flex w-full justify-between"> <SelectValue placeholder="내외자 구분 선택" /> {field.value && ( <Button type="button" variant="ghost" size="icon" - className="h-4 w-4 -mr-2" + className="-mr-2 h-4 w-4" onClick={(e) => { e.stopPropagation(); form.setValue("domesticForeign", ""); @@ -645,9 +436,9 @@ export function PeriodicEvaluationFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {domesticForeignOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} + {EVALUATION_TARGET_FILTER_OPTIONS.DOMESTIC_FOREIGN.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} </SelectItem> ))} </SelectContent> @@ -671,14 +462,14 @@ export function PeriodicEvaluationFilterSheet({ > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> + <div className="flex w-full justify-between"> <SelectValue placeholder="자재구분 선택" /> {field.value && ( <Button type="button" variant="ghost" size="icon" - className="h-4 w-4 -mr-2" + className="-mr-2 h-4 w-4" onClick={(e) => { e.stopPropagation(); form.setValue("materialType", ""); @@ -692,9 +483,9 @@ export function PeriodicEvaluationFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {materialTypeOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} + {EVALUATION_TARGET_FILTER_OPTIONS.MATERIAL_TYPES.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} </SelectItem> ))} </SelectContent> @@ -716,8 +507,8 @@ export function PeriodicEvaluationFilterSheet({ <Input placeholder="벤더 코드 입력" {...field} - className={cn(field.value && "pr-8", "bg-white")} disabled={isInitializing} + className={cn(field.value && "pr-8", "bg-white")} /> {field.value && ( <Button @@ -753,8 +544,8 @@ export function PeriodicEvaluationFilterSheet({ <Input placeholder="벤더명 입력" {...field} - className={cn(field.value && "pr-8", "bg-white")} disabled={isInitializing} + className={cn(field.value && "pr-8", "bg-white")} /> {field.value && ( <Button @@ -792,14 +583,14 @@ export function PeriodicEvaluationFilterSheet({ > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> + <div className="flex w-full justify-between"> <SelectValue placeholder="문서제출여부 선택" /> {field.value && ( <Button type="button" variant="ghost" size="icon" - className="h-4 w-4 -mr-2" + className="-mr-2 h-4 w-4" onClick={(e) => { e.stopPropagation(); form.setValue("documentsSubmitted", ""); @@ -813,9 +604,9 @@ export function PeriodicEvaluationFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {documentsSubmittedOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} + {documentsSubmittedOptions.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} </SelectItem> ))} </SelectContent> @@ -839,14 +630,14 @@ export function PeriodicEvaluationFilterSheet({ > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> + <div className="flex w-full justify-between"> <SelectValue placeholder="평가등급 선택" /> {field.value && ( <Button type="button" variant="ghost" size="icon" - className="h-4 w-4 -mr-2" + className="-mr-2 h-4 w-4" onClick={(e) => { e.stopPropagation(); form.setValue("evaluationGrade", ""); @@ -860,9 +651,9 @@ export function PeriodicEvaluationFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {gradeOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} + {gradeOptions.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} </SelectItem> ))} </SelectContent> @@ -886,14 +677,14 @@ export function PeriodicEvaluationFilterSheet({ > <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> + <div className="flex w-full justify-between"> <SelectValue placeholder="최종등급 선택" /> {field.value && ( <Button type="button" variant="ghost" size="icon" - className="h-4 w-4 -mr-2" + className="-mr-2 h-4 w-4" onClick={(e) => { e.stopPropagation(); form.setValue("finalGrade", ""); @@ -907,9 +698,9 @@ export function PeriodicEvaluationFilterSheet({ </SelectTrigger> </FormControl> <SelectContent> - {gradeOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} + {gradeOptions.map((opt) => ( + <SelectItem key={opt.value} value={opt.value}> + {opt.label} </SelectItem> ))} </SelectContent> @@ -934,8 +725,8 @@ export function PeriodicEvaluationFilterSheet({ step="0.1" placeholder="최소" {...field} - className={cn(field.value && "pr-8", "bg-white")} disabled={isInitializing} + className={cn(field.value && "pr-8", "bg-white")} /> {field.value && ( <Button @@ -972,8 +763,8 @@ export function PeriodicEvaluationFilterSheet({ step="0.1" placeholder="최대" {...field} - className={cn(field.value && "pr-8", "bg-white")} disabled={isInitializing} + className={cn(field.value && "pr-8", "bg-white")} /> {field.value && ( <Button @@ -997,18 +788,17 @@ export function PeriodicEvaluationFilterSheet({ )} /> </div> - </div> </div> - {/* Fixed buttons at bottom */} - <div className="p-4 shrink-0"> - <div className="flex gap-2 justify-end"> + {/* Footer buttons */} + <div className="shrink-0 p-4"> + <div className="flex justify-end gap-2"> <Button type="button" variant="outline" onClick={handleReset} - disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + disabled={isPending } className="px-4" > 초기화 @@ -1016,10 +806,10 @@ export function PeriodicEvaluationFilterSheet({ <Button type="submit" variant="samsung" - disabled={isPending || isLoading || isInitializing} + disabled={isPending || isLoading } className="px-4" > - <Search className="size-4 mr-2" /> + <Search className="mr-2 size-4" /> {isPending || isLoading ? "조회 중..." : "조회"} </Button> </div> @@ -1027,5 +817,5 @@ export function PeriodicEvaluationFilterSheet({ </form> </Form> </div> - ) -}
\ No newline at end of file + ); +} diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx index d4510eb5..257225c8 100644 --- a/lib/evaluation/table/evaluation-table.tsx +++ b/lib/evaluation/table/evaluation-table.tsx @@ -1,12 +1,21 @@ +// lib/evaluation/table/evaluation-table.tsx - 최종 정리된 버전 + "use client" import * as React from "react" import { useRouter, useSearchParams } from "next/navigation" import { Button } from "@/components/ui/button" -import { PanelLeftClose, PanelLeftOpen } from "lucide-react" +import { PanelLeftClose, PanelLeftOpen, BarChart3, List, Info } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Skeleton } from "@/components/ui/skeleton" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" import type { DataTableAdvancedFilterField, DataTableFilterField, @@ -19,22 +28,106 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { cn } from "@/lib/utils" import { useTablePresets } from "@/components/data-table/use-table-presets" import { TablePresetManager } from "@/components/data-table/data-table-preset" -import { useMemo } from "react" import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet" import { getPeriodicEvaluationsColumns } from "./evaluation-columns" -import { PeriodicEvaluationView } from "@/db/schema" -import { getPeriodicEvaluations, getPeriodicEvaluationsStats } from "../service" +import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema" +import { + getPeriodicEvaluationsWithAggregation, + getPeriodicEvaluationsStats +} from "../service" import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions" import { EvaluationDetailsDialog } from "./evaluation-details-dialog" +import { searchParamsEvaluationsCache } from "../validation" interface PeriodicEvaluationsTableProps { - promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]> + promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluationsWithAggregation>>]> evaluationYear: number + initialViewMode?: "detailed" | "aggregated" // ✅ 페이지에서 전달받는 초기 모드 className?: string } +// 뷰 모드 토글 컴포넌트 +function EvaluationViewToggle({ + value, + onValueChange, + detailedCount, + aggregatedCount, +}: { + value: "detailed" | "aggregated"; + onValueChange: (value: "detailed" | "aggregated") => void; + detailedCount?: number; + aggregatedCount?: number; +}) { + return ( + <div className="flex items-center gap-2"> + <ToggleGroup + type="single" + value={value} + onValueChange={(newValue) => { + if (newValue) onValueChange(newValue as "detailed" | "aggregated"); + }} + className="bg-muted p-1 rounded-lg" + > + <ToggleGroupItem + value="detailed" + aria-label="상세 뷰" + className="flex items-center gap-2 data-[state=on]:bg-background" + > + <List className="h-4 w-4" /> + <span>상세 뷰</span> + {detailedCount !== undefined && ( + <Badge variant="secondary" className="ml-1 text-xs"> + {detailedCount} + </Badge> + )} + </ToggleGroupItem> + + <ToggleGroupItem + value="aggregated" + aria-label="집계 뷰" + className="flex items-center gap-2 data-[state=on]:bg-background" + > + <BarChart3 className="h-4 w-4" /> + <span>집계 뷰</span> + {aggregatedCount !== undefined && ( + <Badge variant="secondary" className="ml-1 text-xs"> + {aggregatedCount} + </Badge> + )} + </ToggleGroupItem> + </ToggleGroup> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="ghost" size="icon" className="h-8 w-8"> + <Info className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom" className="max-w-sm"> + <div className="space-y-2 text-sm"> + <div> + <strong>상세 뷰:</strong> 모든 평가 기록을 개별적으로 표시 + </div> + <div> + <strong>집계 뷰:</strong> 동일 벤더의 여러 division 평가를 평균으로 통합하여 표시 + </div> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ); +} + // 통계 카드 컴포넌트 -function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }) { +function PeriodicEvaluationsStats({ + evaluationYear, + viewMode +}: { + evaluationYear: number; + viewMode: "detailed" | "aggregated"; +}) { const [stats, setStats] = React.useState<any>(null) const [isLoading, setIsLoading] = React.useState(true) const [error, setError] = React.useState<string | null>(null) @@ -47,8 +140,11 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number } setIsLoading(true) setError(null) - // 실제 통계 함수 호출 - const statsData = await getPeriodicEvaluationsStats(evaluationYear) + // 뷰 모드에 따라 다른 통계 함수 호출 + const statsData = await getPeriodicEvaluationsStats( + evaluationYear, + viewMode === "aggregated" + ) if (isMounted) { setStats(statsData) @@ -70,7 +166,7 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number } return () => { isMounted = false } - }, [evaluationYear]) // evaluationYear 의존성 추가 + }, [evaluationYear, viewMode]) if (isLoading) { return ( @@ -114,13 +210,25 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number } {/* 총 평가 */} <Card> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">총 평가</CardTitle> - <Badge variant="outline">{evaluationYear}년</Badge> + <CardTitle className="text-sm font-medium"> + 총 {viewMode === "aggregated" ? "벤더" : "평가"} + </CardTitle> + <div className="flex items-center gap-1"> + <Badge variant="outline">{evaluationYear}년</Badge> + {viewMode === "aggregated" && ( + <Badge variant="secondary" className="text-xs">집계</Badge> + )} + </div> </CardHeader> <CardContent> <div className="text-2xl font-bold">{totalEvaluations.toLocaleString()}</div> <div className="text-xs text-muted-foreground mt-1"> 평균점수 {stats.averageScore?.toFixed(1) || 0}점 + {viewMode === "aggregated" && stats.totalEvaluationCount && ( + <span className="ml-2"> + (총 {stats.totalEvaluationCount}개 평가) + </span> + )} </div> </CardContent> </Card> @@ -172,12 +280,55 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number } ) } -export function PeriodicEvaluationsTable({ promises, evaluationYear, className }: PeriodicEvaluationsTableProps) { - const [rowAction, setRowAction] = React.useState<DataTableRowAction<PeriodicEvaluationView> | null>(null) - const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) +export function PeriodicEvaluationsTable({ + promises, + evaluationYear, + initialViewMode = "detailed", + className +}: PeriodicEvaluationsTableProps) { const router = useRouter() const searchParams = useSearchParams() - + + // ✅ URL에서 현재 집계 모드 상태 읽기 + const currentParams = searchParamsEvaluationsCache.parse(Object.fromEntries(searchParams.entries())) + const [viewMode, setViewMode] = React.useState<"detailed" | "aggregated">( + currentParams.aggregated ? "aggregated" : "detailed" + ) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PeriodicEvaluationView | PeriodicEvaluationAggregatedView> | null>(null) + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + const [detailedCount, setDetailedCount] = React.useState<number | undefined>(undefined) + const [aggregatedCount, setAggregatedCount] = React.useState<number | undefined>(undefined) + + const [externalFilters, setExternalFilters] = React.useState<any[]>([]); + const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); + + // ✅ 뷰 모드 변경 시 URL 업데이트 + const handleViewModeChange = React.useCallback((newMode: "detailed" | "aggregated") => { + setViewMode(newMode); + + // URL 파라미터 업데이트 + const newSearchParams = new URLSearchParams(searchParams.toString()) + if (newMode === "aggregated") { + newSearchParams.set("aggregated", "true") + } else { + newSearchParams.delete("aggregated") + } + + // 페이지를 1로 리셋 + newSearchParams.set("page", "1") + + router.push(`?${newSearchParams.toString()}`, { scroll: false }) + }, [router, searchParams]) + + const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { + console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); + setExternalFilters(filters); + setExternalJoinOperator(joinOperator); + setIsFilterPanelOpen(false); + }, []); + + // 컨테이너 위치 추적 const containerRef = React.useRef<HTMLDivElement>(null) const [containerTop, setContainerTop] = React.useState(0) @@ -185,7 +336,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect() const newTop = rect.top - setContainerTop(prevTop => { if (Math.abs(prevTop - newTop) > 1) { return newTop @@ -195,69 +345,44 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } } }, []) - const throttledUpdateBounds = React.useCallback(() => { - let timeoutId: NodeJS.Timeout - return () => { - clearTimeout(timeoutId) - timeoutId = setTimeout(updateContainerBounds, 16) - } - }, [updateContainerBounds]) - React.useEffect(() => { updateContainerBounds() - - const throttledHandler = throttledUpdateBounds() - - const handleResize = () => { - updateContainerBounds() + const throttledHandler = () => { + let timeoutId: NodeJS.Timeout + return () => { + clearTimeout(timeoutId) + timeoutId = setTimeout(updateContainerBounds, 16) + } } - window.addEventListener('resize', handleResize) - window.addEventListener('scroll', throttledHandler) + const handler = throttledHandler() + window.addEventListener('resize', updateContainerBounds) + window.addEventListener('scroll', handler) return () => { - window.removeEventListener('resize', handleResize) - window.removeEventListener('scroll', throttledHandler) + window.removeEventListener('resize', updateContainerBounds) + window.removeEventListener('scroll', handler) } - }, [updateContainerBounds, throttledUpdateBounds]) + }, [updateContainerBounds]) + // 데이터 로드 const [promiseData] = React.use(promises) const tableData = promiseData - - const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => { - return searchParams?.get(key) ?? defaultValue ?? ""; - }, [searchParams]); - - const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { - try { - const value = getSearchParam(key); - return value ? JSON.parse(value) : defaultValue; - } catch { - return defaultValue; - } - }, [getSearchParam]); - - const parseSearchParam = <T,>(key: string, defaultValue: T): T => { - return parseSearchParamHelper(key, defaultValue); - }; - + // 테이블 설정 const initialSettings = React.useMemo(() => ({ - page: parseInt(getSearchParam('page') || '1'), - perPage: parseInt(getSearchParam('perPage') || '10'), - sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }], - filters: getSearchParam('filters') ? JSON.parse(getSearchParam('filters')!) : [], - joinOperator: (getSearchParam('joinOperator') as "and" | "or") || "and", - basicFilters: getSearchParam('basicFilters') ? - JSON.parse(getSearchParam('basicFilters')!) : [], - basicJoinOperator: (getSearchParam('basicJoinOperator') as "and" | "or") || "and", - search: getSearchParam('search') || '', + page: currentParams.page || 1, + perPage: currentParams.perPage || 10, + sort: currentParams.sort || [{ id: "createdAt", desc: true }], + filters: currentParams.filters || [], + joinOperator: currentParams.joinOperator || "and", + search: "", columnVisibility: {}, columnOrder: [], pinnedColumns: { left: [], right: ["actions"] }, groupBy: [], expandedRows: [] - }), [searchParams]) + }), [currentParams]) const { presets, @@ -270,28 +395,31 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } deletePreset, setDefaultPreset, renamePreset, - updateClientState, getCurrentSettings, - } = useTablePresets<PeriodicEvaluationView>('periodic-evaluations-table', initialSettings) + } = useTablePresets<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>('periodic-evaluations-table', initialSettings) - const columns = React.useMemo( - () => getPeriodicEvaluationsColumns({ setRowAction }), - [setRowAction] - ) + // 집계 모드에 따라 컬럼 수정 + const columns = React.useMemo(() => { + return getPeriodicEvaluationsColumns({ + setRowAction, + viewMode + }); + }, [viewMode, setRowAction]); - const filterFields: DataTableFilterField<PeriodicEvaluationView>[] = [ + const filterFields: DataTableFilterField<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] = [ { id: "vendorCode", label: "벤더 코드" }, { id: "vendorName", label: "벤더명" }, { id: "status", label: "진행상태" }, ] - const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] = [ { id: "evaluationYear", label: "평가년도", type: "number" }, { id: "evaluationPeriod", label: "평가기간", type: "text" }, { id: "division", label: "구분", type: "select", options: [ { label: "해양", value: "PLANT" }, { label: "조선", value: "SHIP" }, + ...(viewMode === "aggregated" ? [{ label: "통합", value: "BOTH" }] : []), ] }, { id: "vendorCode", label: "벤더 코드", type: "text" }, @@ -311,7 +439,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } { label: "미제출", value: "false" }, ] }, - { id: "totalScore", label: "총점", type: "number" }, { id: "finalScore", label: "최종점수", type: "number" }, { id: "submissionDate", label: "제출일", type: "date" }, { id: "reviewCompletedAt", label: "검토완료일", type: "date" }, @@ -326,7 +453,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } columnPinning: currentSettings.pinnedColumns, }), [columns, currentSettings, initialSettings.sort]); - const { table } = useDataTable({ data: tableData.data, columns, @@ -341,18 +467,13 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } clearOnDefault: true, }) - const handleSearch = () => { - setIsFilterPanelOpen(false) - } - - const getActiveBasicFilterCount = () => { + const getActiveFilterCount = React.useCallback(() => { try { - const basicFilters = getSearchParam('basicFilters') - return basicFilters ? JSON.parse(basicFilters).length : 0 - } catch (e) { - return 0 + return currentParams.filters?.length || 0; + } catch { + return 0; } - } + }, [currentParams.filters]); const FILTER_PANEL_WIDTH = 400; @@ -374,7 +495,7 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } <PeriodicEvaluationFilterSheet isOpen={isFilterPanelOpen} onClose={() => setIsFilterPanelOpen(false)} - onSearch={handleSearch} + onFiltersApply={handleFiltersApply} isLoading={false} /> </div> @@ -393,35 +514,56 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' }} > - {/* Header Bar */} + {/* Header Bar with View Toggle */} <div className="flex items-center justify-between p-4 bg-background shrink-0"> <div className="flex items-center gap-3"> <Button variant="outline" size="sm" - type='button' + type="button" onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} className="flex items-center shadow-sm" > {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />} - {getActiveBasicFilterCount() > 0 && ( + {getActiveFilterCount() > 0 && ( <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> - {getActiveBasicFilterCount()} + {getActiveFilterCount()} </span> )} </Button> + + {/* ✅ 뷰 모드 토글 */} + <EvaluationViewToggle + value={viewMode} + onValueChange={handleViewModeChange} + detailedCount={detailedCount} + aggregatedCount={aggregatedCount} + /> </div> - <div className="text-sm text-muted-foreground"> - {tableData && ( - <span>총 {tableData.total || tableData.data.length}건</span> - )} + <div className="flex items-center gap-4"> + <div className="text-sm text-muted-foreground"> + {viewMode === "detailed" ? ( + <span>모든 평가 기록 표시</span> + ) : ( + <span>벤더별 통합 평가 표시</span> + )} + </div> + + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || tableData.data.length}건</span> + )} + </div> </div> </div> {/* 통계 카드들 */} <div className="px-4"> - <PeriodicEvaluationsStats evaluationYear={evaluationYear} /> + <PeriodicEvaluationsStats + evaluationYear={evaluationYear} + viewMode={viewMode} + /> </div> {/* Table Content Area */} @@ -431,10 +573,16 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} + debounceMs={300} shallow={false} + externalFilters={externalFilters} + externalJoinOperator={externalJoinOperator} + onFiltersChange={(filters, joinOperator) => { + console.log("=== 필터 변경 감지 ===", filters, joinOperator); + }} > <div className="flex items-center gap-2"> - <TablePresetManager<PeriodicEvaluationView> + <TablePresetManager<PeriodicEvaluationView | PeriodicEvaluationAggregatedView> presets={presets} activePresetId={activePresetId} currentSettings={currentSettings} @@ -448,9 +596,7 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } onRenamePreset={renamePreset} /> - <PeriodicEvaluationsTableToolbarActions - table={table} - /> + <PeriodicEvaluationsTableToolbarActions table={table} /> </div> </DataTableAdvancedToolbar> </DataTable> @@ -459,12 +605,11 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } open={rowAction?.type === "view"} onOpenChange={(open) => { if (!open) { - setRowAction(null) + setRowAction(null); } }} evaluation={rowAction?.row.original || null} /> - </div> </div> </div> diff --git a/lib/evaluation/table/evaluation-view-toggle.tsx b/lib/evaluation/table/evaluation-view-toggle.tsx new file mode 100644 index 00000000..e4fed6a8 --- /dev/null +++ b/lib/evaluation/table/evaluation-view-toggle.tsx @@ -0,0 +1,88 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Info, BarChart3, List } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface EvaluationViewToggleProps { + value: "detailed" | "aggregated"; + onValueChange: (value: "detailed" | "aggregated") => void; + detailedCount?: number; + aggregatedCount?: number; +} + +export function EvaluationViewToggle({ + value, + onValueChange, + detailedCount, + aggregatedCount, +}: EvaluationViewToggleProps) { + return ( + <div className="flex items-center gap-2"> + <ToggleGroup + type="single" + value={value} + onValueChange={(newValue) => { + if (newValue) onValueChange(newValue as "detailed" | "aggregated"); + }} + className="bg-muted p-1 rounded-lg" + > + <ToggleGroupItem + value="detailed" + aria-label="상세 뷰" + className="flex items-center gap-2 data-[state=on]:bg-background" + > + <List className="h-4 w-4" /> + <span>상세 뷰</span> + {detailedCount !== undefined && ( + <Badge variant="secondary" className="ml-1"> + {detailedCount} + </Badge> + )} + </ToggleGroupItem> + + <ToggleGroupItem + value="aggregated" + aria-label="집계 뷰" + className="flex items-center gap-2 data-[state=on]:bg-background" + > + <BarChart3 className="h-4 w-4" /> + <span>집계 뷰</span> + {aggregatedCount !== undefined && ( + <Badge variant="secondary" className="ml-1"> + {aggregatedCount} + </Badge> + )} + </ToggleGroupItem> + </ToggleGroup> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="ghost" size="icon" className="h-8 w-8"> + <Info className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom" className="max-w-sm"> + <div className="space-y-2 text-sm"> + <div> + <strong>상세 뷰:</strong> 모든 평가 기록을 개별적으로 표시 + </div> + <div> + <strong>집계 뷰:</strong> 동일 벤더의 여러 division 평가를 평균으로 통합하여 표시 + </div> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ); +}
\ No newline at end of file diff --git a/lib/evaluation/validation.ts b/lib/evaluation/validation.ts index 9179f585..f1ed534c 100644 --- a/lib/evaluation/validation.ts +++ b/lib/evaluation/validation.ts @@ -3,7 +3,7 @@ import { parseAsArrayOf, parseAsInteger, parseAsString, - parseAsStringEnum, + parseAsStringEnum,parseAsBoolean } from "nuqs/server"; import * as z from "zod"; @@ -30,13 +30,10 @@ import { periodicEvaluations } from "@/db/schema"; // 고급 필터 filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 베이직 필터 (커스텀 필터 패널용) - basicFilters: getFiltersStateParser().withDefault([]), - basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 검색 - search: parseAsString.withDefault(""), + + + aggregated: parseAsBoolean.withDefault(false), + }); // ============= 타입 정의 ============= diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts new file mode 100644 index 00000000..61e69995 --- /dev/null +++ b/lib/gtc-contract/service.ts @@ -0,0 +1,363 @@ +import { unstable_cache } from "next/cache" +import { and, desc, asc, eq, or, ilike, count, max } from "drizzle-orm" +import db from "@/db/db" +import { gtcDocuments, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { projects } from "@/db/schema/projects" +import { users } from "@/db/schema/users" +import { filterColumns } from "@/lib/filter-columns" +import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema } from "./validations" + +/** + * 프로젝트 존재 여부 확인 + */ +export async function checkProjectExists(projectId: number): Promise<boolean> { + const result = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + + return result.length > 0 +} + +/** + * GTC 문서 관련 뷰/조인 쿼리를 위한 기본 select + */ +function selectGtcDocumentsWithRelations() { + return db + .select({ + id: gtcDocuments.id, + type: gtcDocuments.type, + projectId: gtcDocuments.projectId, + revision: gtcDocuments.revision, + createdAt: gtcDocuments.createdAt, + createdById: gtcDocuments.createdById, + updatedAt: gtcDocuments.updatedAt, + updatedById: gtcDocuments.updatedById, + editReason: gtcDocuments.editReason, + isActive: gtcDocuments.isActive, + // 관계 데이터 + project: { + id: projects.id, + code: projects.code, + name: projects.name, + }, + createdBy: { + id: users.id, + name: users.name, + email: users.email, + }, + updatedBy: { + id: users.id, + name: users.name, + email: users.email, + }, + }) + .from(gtcDocuments) + .leftJoin(projects, eq(gtcDocuments.projectId, projects.id)) + .leftJoin(users, eq(gtcDocuments.createdById, users.id)) + .leftJoin(users, eq(gtcDocuments.updatedById, users.id)) +} + +/** + * GTC 문서 개수 조회 + */ +async function countGtcDocuments(tx: any, where: any) { + const result = await tx + .select({ count: count() }) + .from(gtcDocuments) + .where(where) + + return result[0]?.count ?? 0 +} + +/** + * GTC 문서 목록 조회 (필터링, 정렬, 페이징 지원) + */ +export async function getGtcDocuments(input: GetGtcDocumentsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage + + // (1) advancedWhere - 고급 필터 + const advancedWhere = filterColumns({ + table: gtcDocuments, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // (2) globalWhere - 전역 검색 + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(gtcDocuments.editReason, s), + ilike(projects.name, s), + ilike(projects.code, s) + ) + } + + // (3) 기본 필터들 + const basicFilters = [] + + if (input.type && input.type !== "") { + basicFilters.push(eq(gtcDocuments.type, input.type)) + } + + if (input.projectId && input.projectId > 0) { + basicFilters.push(eq(gtcDocuments.projectId, input.projectId)) + } + + // 활성 문서만 조회 (기본값) + basicFilters.push(eq(gtcDocuments.isActive, true)) + + // (4) 최종 where 조건 + const finalWhere = and( + advancedWhere, + globalWhere, + ...basicFilters + ) + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => { + const column = gtcDocuments[item.id as keyof typeof gtcDocuments] + return item.desc ? desc(column) : asc(column) + }) + : [desc(gtcDocuments.updatedAt)] + + // (6) 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectGtcDocumentsWithRelations() + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(input.perPage) + + const total = await countGtcDocuments(tx, finalWhere) + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + return { data, pageCount } + } catch (err) { + console.error("Error fetching GTC documents:", err) + return { data: [], pageCount: 0 } + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["gtc-documents"], + } + )() +} + +/** + * 특정 GTC 문서 조회 + */ +export async function getGtcDocumentById(id: number): Promise<GtcDocumentWithRelations | null> { + const result = await selectGtcDocumentsWithRelations() + .where(eq(gtcDocuments.id, id)) + .limit(1) + + return result[0] || null +} + +/** + * 다음 리비전 번호 조회 + */ +export async function getNextRevision(type: "standard" | "project", projectId?: number): Promise<number> { + const where = projectId + ? and(eq(gtcDocuments.type, type), eq(gtcDocuments.projectId, projectId)) + : and(eq(gtcDocuments.type, type), eq(gtcDocuments.projectId, null)) + + const result = await db + .select({ maxRevision: max(gtcDocuments.revision) }) + .from(gtcDocuments) + .where(where) + + return (result[0]?.maxRevision ?? -1) + 1 +} + +/** + * GTC 문서 생성 + */ +export async function createGtcDocument( + data: CreateGtcDocumentSchema & { createdById: number } +): Promise<GtcDocument> { + // 리비전 번호가 없는 경우 자동 생성 + if (!data.revision && data.revision !== 0) { + data.revision = await getNextRevision(data.type, data.projectId || undefined) + } + + const [newDocument] = await db + .insert(gtcDocuments) + .values({ + ...data, + updatedById: data.createdById, // 생성시에는 생성자와 수정자가 동일 + }) + .returning() + + return newDocument +} + +/** + * GTC 문서 업데이트 + */ +export async function updateGtcDocument( + id: number, + data: UpdateGtcDocumentSchema & { updatedById: number } +): Promise<GtcDocument | null> { + const [updatedDocument] = await db + .update(gtcDocuments) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(gtcDocuments.id, id)) + .returning() + + return updatedDocument || null +} + +/** + * 새 리비전 생성 + */ +export async function createNewRevision( + originalId: number, + data: CreateNewRevisionSchema & { createdById: number } +): Promise<GtcDocument | null> { + // 원본 문서 조회 + const original = await getGtcDocumentById(originalId) + if (!original) { + throw new Error("Original document not found") + } + + // 다음 리비전 번호 계산 + const nextRevision = await getNextRevision( + original.type, + original.projectId || undefined + ) + + // 새 리비전 생성 + const [newRevision] = await db + .insert(gtcDocuments) + .values({ + type: original.type, + projectId: original.projectId, + revision: nextRevision, + editReason: data.editReason, + createdById: data.createdById, + updatedById: data.createdById, + }) + .returning() + + return newRevision +} + +/** + * GTC 문서 삭제 (소프트 삭제) + */ +export async function deleteGtcDocument( + id: number, + updatedById: number +): Promise<boolean> { + const [updated] = await db + .update(gtcDocuments) + .set({ + isActive: false, + updatedById, + updatedAt: new Date(), + }) + .where(eq(gtcDocuments.id, id)) + .returning() + + return !!updated +} + +/** + * 프로젝트별 GTC 문서 목록 조회 + */ +export async function getGtcDocumentsByProject(projectId: number): Promise<GtcDocumentWithRelations[]> { + return await selectGtcDocumentsWithRelations() + .where( + and( + eq(gtcDocuments.projectId, projectId), + eq(gtcDocuments.isActive, true) + ) + ) + .orderBy(desc(gtcDocuments.revision)) +} + +/** + * 표준 GTC 문서 목록 조회 + */ +export async function getStandardGtcDocuments(): Promise<GtcDocumentWithRelations[]> { + return await selectGtcDocumentsWithRelations() + .where( + and( + eq(gtcDocuments.type, "standard"), + eq(gtcDocuments.isActive, true) + ) + ) + .orderBy(desc(gtcDocuments.revision)) +} + +// 타입 정의 +export type ProjectForFilter = { + id: number + code: string + name: string +} + +export type UserForFilter = { + id: number + name: string + email: string +} + +/** + * 프로젝트 목록 조회 (필터용) + */ +export async function getProjectsForFilter(): Promise<ProjectForFilter[]> { + return await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .orderBy(projects.name) +} + +/** + * 프로젝트 목록 조회 (선택용) + */ +export async function getProjectsForSelect(): Promise<ProjectForFilter[]> { + return await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .orderBy(projects.name) +} + +/** + * 사용자 목록 조회 (필터용) + */ +export async function getUsersForFilter(): Promise<UserForFilter[]> { + return await db + .select({ + id: users.id, + name: users.name, + email: users.email, + }) + .from(users) + .where(eq(users.isActive, true)) // 활성 사용자만 + .orderBy(users.name) +}
\ No newline at end of file diff --git a/lib/gtc-contract/status/create-gtc-document-dialog.tsx b/lib/gtc-contract/status/create-gtc-document-dialog.tsx new file mode 100644 index 00000000..6791adfa --- /dev/null +++ b/lib/gtc-contract/status/create-gtc-document-dialog.tsx @@ -0,0 +1,272 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Loader, Plus } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +import { createGtcDocumentSchema, type CreateGtcDocumentSchema } from "@/lib/gtc-contract/validations" +import { createGtcDocument, getProjectsForSelect } from "@/lib/gtc-contract/service" +import { type Project } from "@/db/schema/projects" + +export function CreateGtcDocumentDialog() { + const [open, setOpen] = React.useState(false) + const [projects, setProjects] = React.useState<Project[]>([]) + const [isCreatePending, startCreateTransition] = React.useTransition() + + React.useEffect(() => { + if (open) { + getProjectsForSelect().then((res) => { + setProjects(res) + }) + } + }, [open]) + + const form = useForm<CreateGtcDocumentSchema>({ + resolver: zodResolver(createGtcDocumentSchema), + defaultValues: { + type: "standard", + projectId: null, + revision: 0, + editReason: "", + }, + }) + + const watchedType = form.watch("type") + + async function onSubmit(data: CreateGtcDocumentSchema) { + startCreateTransition(async () => { + try { + const result = await createGtcDocument(data) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + form.reset() + setOpen(false) + toast.success("GTC 문서가 생성되었습니다.") + } catch (error) { + toast.error("문서 생성 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <Plus className="mr-2 h-4 w-4" /> + Add GTC Document + </Button> + </DialogTrigger> + + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Create New GTC Document</DialogTitle> + <DialogDescription> + 새 GTC 문서 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* 구분 (Type) */} + <FormField + control={form.control} + name="type" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <FormControl> + <Select + onValueChange={(value) => { + field.onChange(value) + // 표준으로 변경시 프로젝트 ID 초기화 + if (value === "standard") { + form.setValue("projectId", null) + } + }} + value={field.value} + > + <SelectTrigger> + <SelectValue placeholder="구분을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="standard">표준</SelectItem> + <SelectItem value="project">프로젝트</SelectItem> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트 선택 (프로젝트 타입인 경우만) */} + {watchedType === "project" && ( + <FormField + control={form.control} + name="projectId" + render={({ field }) => { + const selectedProject = projects.find( + (p) => p.id === field.value + ) + const [popoverOpen, setPopoverOpen] = React.useState(false) + + return ( + <FormItem> + <FormLabel>프로젝트</FormLabel> + <FormControl> + <Popover + open={popoverOpen} + onOpenChange={setPopoverOpen} + modal={true} + > + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {selectedProject + ? `${selectedProject.name} (${selectedProject.code})` + : "프로젝트를 선택하세요..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput + placeholder="프로젝트 검색..." + className="h-9" + /> + <CommandList> + <CommandEmpty>프로젝트를 찾을 수 없습니다.</CommandEmpty> + <CommandGroup> + {projects.map((project) => { + const label = `${project.name} (${project.code})` + return ( + <CommandItem + key={project.id} + value={label} + onSelect={() => { + field.onChange(project.id) + setPopoverOpen(false) + }} + > + {label} + <Check + className={cn( + "ml-auto h-4 w-4", + selectedProject?.id === project.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + }} + /> + )} + + {/* 편집 사유 */} + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>편집 사유 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="편집 사유를 입력하세요..." + {...field} + rows={3} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isCreatePending} + > + Cancel + </Button> + <Button type="submit" disabled={isCreatePending}> + {isCreatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/status/create-new-revision-dialog.tsx b/lib/gtc-contract/status/create-new-revision-dialog.tsx new file mode 100644 index 00000000..e18e6352 --- /dev/null +++ b/lib/gtc-contract/status/create-new-revision-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Loader } from "lucide-react" +import { toast } from "sonner" + +import { createNewRevisionSchema, type CreateNewRevisionSchema } from "@/lib/gtc-contract/validations" +import { createNewRevision } from "@/lib/gtc-contract/service" +import { type GtcDocumentWithRelations } from "@/db/schema/gtc" + +interface CreateNewRevisionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + originalDocument: GtcDocumentWithRelations | null +} + +export function CreateNewRevisionDialog({ + open, + onOpenChange, + originalDocument, +}: CreateNewRevisionDialogProps) { + const [isCreatePending, startCreateTransition] = React.useTransition() + + const form = useForm<CreateNewRevisionSchema>({ + resolver: zodResolver(createNewRevisionSchema), + defaultValues: { + editReason: "", + }, + }) + + // 다이얼로그가 열릴 때마다 폼 초기화 + React.useEffect(() => { + if (open && originalDocument) { + form.reset({ + editReason: "", + }) + } + }, [open, originalDocument, form]) + + async function onSubmit(data: CreateNewRevisionSchema) { + if (!originalDocument) { + toast.error("원본 문서 정보가 없습니다.") + return + } + + startCreateTransition(async () => { + try { + const result = await createNewRevision(originalDocument.id, data) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + form.reset() + onOpenChange(false) + toast.success(`새 리비전 v${result.revision}이 생성되었습니다.`) + } catch (error) { + toast.error("리비전 생성 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + onOpenChange(nextOpen) + } + + if (!originalDocument) return null + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>Create New Revision</DialogTitle> + <DialogDescription> + 기존 문서의 새로운 리비전을 생성합니다. + </DialogDescription> + </DialogHeader> + + {/* 원본 문서 정보 표시 */} + <div className="space-y-2 p-3 bg-muted/50 rounded-lg"> + <div className="text-sm font-medium">원본 문서 정보</div> + <div className="text-xs text-muted-foreground space-y-1"> + <div>구분: {originalDocument.type === "standard" ? "표준" : "프로젝트"}</div> + {originalDocument.project && ( + <div>프로젝트: {originalDocument.project.name} ({originalDocument.project.code})</div> + )} + <div>현재 리비전: v{originalDocument.revision}</div> + </div> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* 편집 사유 (필수) */} + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>편집 사유 *</FormLabel> + <FormControl> + <Textarea + placeholder="새 리비전 생성 사유를 입력하세요..." + {...field} + rows={3} + required + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isCreatePending} + > + Cancel + </Button> + <Button type="submit" disabled={isCreatePending}> + {isCreatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Create Revision + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx b/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx new file mode 100644 index 00000000..5779a2b6 --- /dev/null +++ b/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx @@ -0,0 +1,168 @@ +"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 { deleteGtcDocuments } from "@/lib/gtc-contract/service" +import { type GtcDocumentWithRelations } from "@/db/schema/gtc" + +interface DeleteGtcDocumentsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + gtcDocuments: Row<GtcDocumentWithRelations>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteGtcDocumentsDialog({ + gtcDocuments, + showTrigger = true, + onSuccess, + ...props +}: DeleteGtcDocumentsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await deleteGtcDocuments({ + ids: gtcDocuments.map((doc) => doc.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success( + `${gtcDocuments.length}개의 GTC 문서가 삭제되었습니다.` + ) + onSuccess?.() + }) + } + + const title = "Are you absolutely sure?" + const description = ( + <> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{gtcDocuments.length}개</span>의 GTC 문서가 영구적으로 삭제됩니다. + {gtcDocuments.length > 0 && ( + <div className="mt-2 max-h-32 overflow-y-auto"> + <div className="text-sm text-muted-foreground">삭제될 문서:</div> + <ul className="mt-1 text-sm"> + {gtcDocuments.slice(0, 5).map((doc) => ( + <li key={doc.id} className="truncate"> + • {doc.fileName} (v{doc.revision}) + </li> + ))} + {gtcDocuments.length > 5 && ( + <li className="text-muted-foreground"> + ... 외 {gtcDocuments.length - 5}개 + </li> + )} + </ul> + </div> + )} + </> + ) + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({gtcDocuments.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected documents" + 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 ({gtcDocuments.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>{title}</DrawerTitle> + <DrawerDescription>{description}</DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected documents" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/status/gtc-contract-table.tsx b/lib/gtc-contract/status/gtc-contract-table.tsx new file mode 100644 index 00000000..dd04fbc9 --- /dev/null +++ b/lib/gtc-contract/status/gtc-contract-table.tsx @@ -0,0 +1,173 @@ +"use client" + +import * as React from "react" +import { gtcDocuments, type GtcDocumentWithRelations } from "@/db/schema/gtc" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + +import type { + getGtcDocuments, + getProjectsForFilter, + getUsersForFilter +} from "@/lib/gtc-contract/service" +import { getColumns } from "./gtc-documents-table-columns" +import { GtcDocumentsTableToolbarActions } from "./gtc-documents-table-toolbar-actions" +import { DeleteGtcDocumentsDialog } from "./delete-gtc-documents-dialog" +import { GtcDocumentsTableFloatingBar } from "./gtc-documents-table-floating-bar" +import { UpdateGtcDocumentSheet } from "./update-gtc-document-sheet" +import { CreateGtcDocumentDialog } from "./create-gtc-document-dialog" +import { CreateNewRevisionDialog } from "./create-new-revision-dialog" + +interface GtcDocumentsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getGtcDocuments>>, + Awaited<ReturnType<typeof getProjectsForFilter>>, + Awaited<ReturnType<typeof getUsersForFilter>> + ] + > +} + +export function GtcDocumentsTable({ promises }: GtcDocumentsTableProps) { + const [{ data, pageCount }, projects, users] = React.use(promises) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<GtcDocumentWithRelations> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * Filter fields for the data table. + */ + const filterFields: DataTableFilterField<GtcDocumentWithRelations>[] = [ + { + id: "editReason", + label: "Edit Reason", + placeholder: "Filter by edit reason...", + }, + ] + + /** + * Advanced filter fields for the data table. + */ + const advancedFilterFields: DataTableAdvancedFilterField<GtcDocumentWithRelations>[] = [ + { + id: "type", + label: "Type", + type: "multi-select", + options: [ + { label: "Standard", value: "standard" }, + { label: "Project", value: "project" }, + ], + }, + { + id: "editReason", + label: "Edit Reason", + type: "text", + }, + { + id: "project.name", + label: "Project", + type: "multi-select", + options: projects.map((project) => ({ + label: `${project.name} (${project.code})`, + value: project.name, + })), + }, + { + id: "createdBy.name", + label: "Created By", + type: "multi-select", + options: users.map((user) => ({ + label: user.name, + value: user.name, + })), + }, + { + id: "updatedBy.name", + label: "Updated By", + type: "multi-select", + options: users.map((user) => ({ + label: user.name, + value: user.name, + })), + }, + { + id: "createdAt", + label: "Created At", + type: "date", + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "updatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.id}`, + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<GtcDocumentsTableFloatingBar table={table} />} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <GtcDocumentsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + <DeleteGtcDocumentsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + gtcDocuments={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + <UpdateGtcDocumentSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + gtcDocument={rowAction?.row.original ?? null} + /> + + <CreateNewRevisionDialog + open={rowAction?.type === "createRevision"} + onOpenChange={() => setRowAction(null)} + originalDocument={rowAction?.row.original ?? null} + /> + + <CreateGtcDocumentDialog /> + </> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/status/gtc-documents-table-columns.tsx b/lib/gtc-contract/status/gtc-documents-table-columns.tsx new file mode 100644 index 00000000..2d5f08b9 --- /dev/null +++ b/lib/gtc-contract/status/gtc-documents-table-columns.tsx @@ -0,0 +1,291 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, Eye } from "lucide-react" + +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, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { useRouter } from "next/navigation" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GtcDocumentWithRelations> | null>> +} + +/** + * GTC Documents 테이블 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<GtcDocumentWithRelations>[] { + const router = useRouter() + + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<GtcDocumentWithRelations> = { + 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" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 기본 정보 그룹 + // ---------------------------------------------------------------- + const basicInfoColumns: ColumnDef<GtcDocumentWithRelations>[] = [ + { + accessorKey: "type", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />, + cell: ({ row }) => { + const type = row.getValue("type") as string; + return ( + <Badge variant={type === "standard" ? "default" : "secondary"}> + {type === "standard" ? "표준" : "프로젝트"} + </Badge> + ); + }, + size: 100, + enableResizing: true, + meta: { + excelHeader: "구분", + }, + }, + { + accessorKey: "project", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트" />, + cell: ({ row }) => { + const project = row.original.project; + if (!project) { + return <span className="text-muted-foreground">-</span>; + } + return ( + <div className="flex flex-col min-w-0"> + <span className="font-medium truncate">{project.name}</span> + <span className="text-xs text-muted-foreground">{project.code}</span> + </div> + ); + }, + size: 200, + enableResizing: true, + meta: { + excelHeader: "프로젝트", + }, + }, + { + accessorKey: "revision", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Rev." />, + cell: ({ row }) => { + const revision = row.getValue("revision") as number; + return <span className="font-mono text-sm">v{revision}</span>; + }, + size: 80, + enableResizing: true, + meta: { + excelHeader: "Rev.", + }, + }, + ]; + + // ---------------------------------------------------------------- + // 3) 등록/수정 정보 그룹 + // ---------------------------------------------------------------- + const auditColumns: ColumnDef<GtcDocumentWithRelations>[] = [ + { + accessorKey: "createdAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최초등록일" />, + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date; + return date ? formatDate(date, "KR") : "-"; + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "최초등록일", + }, + }, + { + accessorKey: "createdBy", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최초등록자" />, + cell: ({ row }) => { + const createdBy = row.original.createdBy; + return createdBy ? ( + <span className="text-sm">{createdBy.name}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "최초등록자", + }, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />, + cell: ({ row }) => { + const date = row.getValue("updatedAt") as Date; + return date ? formatDate(date, "KR") : "-"; + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "최종수정일", + }, + }, + { + accessorKey: "updatedBy", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />, + cell: ({ row }) => { + const updatedBy = row.original.updatedBy; + return updatedBy ? ( + <span className="text-sm">{updatedBy.name}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "최종수정자", + }, + }, + { + accessorKey: "editReason", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종 편집사유" />, + cell: ({ row }) => { + const reason = row.getValue("editReason") as string; + return reason ? ( + <span className="text-sm" title={reason}> + {reason.length > 30 ? `${reason.substring(0, 30)}...` : reason} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 200, + enableResizing: true, + meta: { + excelHeader: "최종 편집사유", + }, + }, + ]; + + // ---------------------------------------------------------------- + // 4) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<GtcDocumentWithRelations> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const gtcDocument = row.original; + + const handleViewDetails = () => { + router.push(`/evcp/gtc-documents/${gtcDocument.id}`); + }; + + const handleCreateNewRevision = () => { + setRowAction({ row, type: "createRevision" }); + }; + + 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-48"> + <DropdownMenuItem onSelect={handleViewDetails}> + <Eye className="mr-2 h-4 w-4" /> + View Details + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuItem onSelect={handleCreateNewRevision}> + Create New Revision + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 5) 중첩 컬럼 그룹 생성 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<GtcDocumentWithRelations>[] = [ + { + id: "기본 정보", + header: "기본 정보", + columns: basicInfoColumns, + }, + { + id: "등록/수정 정보", + header: "등록/수정 정보", + columns: auditColumns, + }, + ] + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx b/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx new file mode 100644 index 00000000..a9139ed2 --- /dev/null +++ b/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Separator } from "@/components/ui/separator" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" + +import { exportTableToCSV } from "@/lib/export" +import { type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { DeleteGtcDocumentsDialog } from "./delete-gtc-documents-dialog" + +interface GtcDocumentsTableFloatingBarProps { + table: Table<GtcDocumentWithRelations> +} + +export function GtcDocumentsTableFloatingBar({ + table, +}: GtcDocumentsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [table]) + + return ( + <div className="fixed inset-x-0 bottom-4 z-50 mx-auto w-fit px-4"> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-card p-2 shadow-2xl"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Clear selection</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + <Button + variant="secondary" + size="sm" + onClick={() => + exportTableToCSV(table, { + filename: "gtc-documents", + excludeColumns: ["select", "actions"], + }) + } + disabled={isPending} + > + <Download className="mr-2 size-4" aria-hidden="true" /> + Export + </Button> + <DeleteGtcDocumentsDialog + gtcDocuments={rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + </div> + </div> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx b/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx new file mode 100644 index 00000000..cb52b2ed --- /dev/null +++ b/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx @@ -0,0 +1,39 @@ +"use client" + +import { type Table } from "@tanstack/react-table" +import { Download } from "lucide-react" + +import { exportTableToCSV } from "@/lib/export" +import { Button } from "@/components/ui/button" + +import { type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { CreateGtcDocumentDialog } from "./create-gtc-document-dialog" + +interface GtcDocumentsTableToolbarActionsProps { + table: Table<GtcDocumentWithRelations> +} + +export function GtcDocumentsTableToolbarActions({ + table, +}: GtcDocumentsTableToolbarActionsProps) { + return ( + <div className="flex items-center gap-2"> + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToCSV(table, { + filename: "gtc-documents", + excludeColumns: ["select", "actions"], + }) + } + > + <Download className="mr-2 size-4" aria-hidden="true" /> + Export ({table.getFilteredSelectedRowModel().rows.length}) + </Button> + ) : null} + <CreateGtcDocumentDialog /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/status/update-gtc-document-sheet.tsx b/lib/gtc-contract/status/update-gtc-document-sheet.tsx new file mode 100644 index 00000000..9d133ecc --- /dev/null +++ b/lib/gtc-contract/status/update-gtc-document-sheet.tsx @@ -0,0 +1,148 @@ +"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 { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Textarea } from "@/components/ui/textarea" + +import { type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { updateGtcDocumentSchema, type UpdateGtcDocumentSchema } from "@/lib/gtc-contract/validations" +import { updateGtcDocument } from "@/lib/gtc-contract/service" + +export interface UpdateGtcDocumentSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + gtcDocument: GtcDocumentWithRelations | null +} + +export function UpdateGtcDocumentSheet({ gtcDocument, ...props }: UpdateGtcDocumentSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateGtcDocumentSchema>({ + resolver: zodResolver(updateGtcDocumentSchema), + defaultValues: { + editReason: "", + isActive: gtcDocument?.isActive ?? true, + }, + }) + + // gtcDocument prop 바뀔 때마다 form.reset + React.useEffect(() => { + if (gtcDocument) { + form.reset({ + editReason: "", + isActive: gtcDocument.isActive, + }) + } + }, [gtcDocument, form]) + + async function onSubmit(input: UpdateGtcDocumentSchema) { + startUpdateTransition(async () => { + if (!gtcDocument) return + + try { + const result = await updateGtcDocument(gtcDocument.id, input) + + if (result.error) { + toast.error(result.error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("GTC 문서가 업데이트되었습니다!") + } catch (error) { + toast.error("문서 업데이트 중 오류가 발생했습니다.") + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update GTC Document</SheetTitle> + <SheetDescription> + GTC 문서 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* 문서 정보 표시 */} + <div className="space-y-2 p-3 bg-muted/50 rounded-lg"> + <div className="text-sm font-medium">현재 문서 정보</div> + <div className="text-xs text-muted-foreground space-y-1"> + <div>구분: {gtcDocument?.type === "standard" ? "표준" : "프로젝트"}</div> + {gtcDocument?.project && ( + <div>프로젝트: {gtcDocument.project.name} ({gtcDocument.project.code})</div> + )} + <div>리비전: v{gtcDocument?.revision}</div> + </div> + </div> + + {/* 편집 사유 */} + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>편집 사유 (권장)</FormLabel> + <FormControl> + <Textarea + placeholder="편집 사유를 입력하세요..." + {...field} + rows={3} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + + <Button type="submit" disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/validations.ts b/lib/gtc-contract/validations.ts new file mode 100644 index 00000000..b79a8b08 --- /dev/null +++ b/lib/gtc-contract/validations.ts @@ -0,0 +1,89 @@ +import { gtcDocuments, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { checkProjectExists } from "./service" + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<GtcDocumentWithRelations>().withDefault([ + { id: "updatedAt", desc: true }, + ]), + // 검색 필터들 + type: parseAsStringEnum(["standard", "project"]).withDefault(""), + projectId: parseAsInteger.withDefault(0), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +export const createGtcDocumentSchema = z.object({ + type: z.enum(["standard", "project"]), + projectId: z + .number() + .nullable() + .optional() + .refine( + async (projectId, ctx) => { + // 프로젝트 타입인 경우 projectId 필수 + if (ctx.parent.type === "project" && !projectId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Project is required for project type GTC", + }) + return false + } + + // 표준 타입인 경우 projectId null이어야 함 + if (ctx.parent.type === "standard" && projectId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Project should not be set for standard type GTC", + }) + return false + } + + // 프로젝트 ID가 유효한지 검사 + if (projectId) { + const exists = await checkProjectExists(projectId) + if (!exists) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid project ID", + }) + return false + } + } + + return true + } + ), + revision: z.number().min(0).default(0), + editReason: z.string().optional(), +}) + +export const updateGtcDocumentSchema = z.object({ + editReason: z.string().optional(), + isActive: z.boolean().optional(), +}) + +// 리비전 업데이트용 스키마 +export const createNewRevisionSchema = z.object({ + editReason: z.string().min(1, "Edit reason is required for new revision"), +}) + +export type GetGtcDocumentsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> +export type CreateGtcDocumentSchema = z.infer<typeof createGtcDocumentSchema> +export type UpdateGtcDocumentSchema = z.infer<typeof updateGtcDocumentSchema> +export type CreateNewRevisionSchema = z.infer<typeof createNewRevisionSchema>
\ No newline at end of file diff --git a/lib/items-tech/service.ts b/lib/items-tech/service.ts index d93c5f96..e0896144 100644 --- a/lib/items-tech/service.ts +++ b/lib/items-tech/service.ts @@ -14,7 +14,7 @@ import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema // 타입 정의 추가
export type ShipbuildingWorkType = '기장' | '전장' | '선실' | '배관' | '철의' | '선체';
-export type OffshoreTopWorkType = 'TM' | 'TS' | 'TE' | 'TP';
+export type OffshoreTopWorkType = 'TM' | 'TS' | 'TE' | 'TP' | 'TA';
export type OffshoreHullWorkType = 'HA' | 'HE' | 'HH' | 'HM' | 'HO' | 'HP' | 'NC';
export interface ShipbuildingItem {
@@ -415,6 +415,27 @@ export async function createShipbuildingItem(input: TypedItemCreateData) { }
const shipData = input as ShipbuildingItemCreateData;
+
+ // 아이템코드 + 선종 조합으로 중복 체크
+ if (input.itemCode && shipData.shipTypes) {
+ const existingItem = await db.select().from(itemShipbuilding)
+ .where(
+ and(
+ eq(itemShipbuilding.itemCode, input.itemCode),
+ eq(itemShipbuilding.shipTypes, shipData.shipTypes)
+ )
+ );
+
+ if (existingItem.length > 0) {
+ return {
+ success: false,
+ message: "중복된 아이템코드 및 선종입니다",
+ data: null,
+ error: "중복 키 오류"
+ }
+ }
+ }
+
const result = await db.insert(itemShipbuilding).values({
itemCode: input.itemCode || "",
workType: shipData.workType ? (shipData.workType as '기장' | '전장' | '선실' | '배관' | '철의' | '선체') : '기장',
@@ -437,7 +458,7 @@ export async function createShipbuildingItem(input: TypedItemCreateData) { if (err instanceof Error && err.message.includes("unique constraint")) {
return {
success: false,
- message: "이미 존재하는 아이템 코드입니다",
+ message: "중복된 아이템코드 및 선종입니다",
data: null,
error: "중복 키 오류"
}
@@ -488,7 +509,7 @@ export async function createShipbuildingImportItem(input: { if (existingItem.length > 0) {
return {
success: false,
- message: "이미 존재하는 아이템 코드 및 선종입니다",
+ message: "중복된 아이템코드 및 선종입니다",
data: null,
error: "중복 키 오류"
}
@@ -517,7 +538,7 @@ export async function createShipbuildingImportItem(input: { if (err instanceof Error && err.message.includes("unique constraint")) {
return {
success: false,
- message: "이미 존재하는 아이템 코드 및 선종입니다",
+ message: "중복된 아이템코드 및 선종입니다",
data: null,
error: "중복 키 오류"
}
@@ -557,7 +578,7 @@ export async function createOffshoreTopItem(data: OffshoreTopItemCreateData) { if (existingItem.length > 0) {
return {
success: false,
- message: "이미 존재하는 아이템 코드입니다",
+ message: "중복된 아이템 코드입니다",
data: null,
error: "중복 키 오류"
};
@@ -586,7 +607,7 @@ export async function createOffshoreTopItem(data: OffshoreTopItemCreateData) { if (err instanceof Error && err.message.includes("unique constraint")) {
return {
success: false,
- message: "이미 존재하는 아이템 코드입니다",
+ message: "중복된 아이템 코드입니다",
data: null,
error: "중복 키 오류"
};
@@ -626,7 +647,7 @@ export async function createOffshoreHullItem(data: OffshoreHullItemCreateData) { if (existingItem.length > 0) {
return {
success: false,
- message: "이미 존재하는 아이템 코드입니다",
+ message: "중복된 아이템 코드입니다",
data: null,
error: "중복 키 오류"
};
@@ -655,7 +676,7 @@ export async function createOffshoreHullItem(data: OffshoreHullItemCreateData) { if (err instanceof Error && err.message.includes("unique constraint")) {
return {
success: false,
- message: "이미 존재하는 아이템 코드입니다",
+ message: "중복된 아이템 코드입니다",
data: null,
error: "중복 키 오류"
};
@@ -735,7 +756,7 @@ export async function modifyOffshoreTopItem(input: UpdateOffshoreTopItemInput) { try {
const updateData: Record<string, unknown> = {};
- if (input.workType) updateData.workType = input.workType as 'TM' | 'TS' | 'TE' | 'TP';
+ if (input.workType) updateData.workType = input.workType as 'TM' | 'TS' | 'TE' | 'TP' | 'TA';
if (input.itemList !== undefined) updateData.itemList = input.itemList;
if (input.subItemList !== undefined) updateData.subItemList = input.subItemList;
if (input.itemCode) updateData.itemCode = input.itemCode;
@@ -781,7 +802,7 @@ export async function modifyOffshoreHullItem(input: UpdateOffshoreHullItemInput) try {
const updateData: Record<string, unknown> = {};
- if (input.workType) updateData.workType = input.workType as 'HA' | 'HE' | 'HH' | 'HM' | 'NC';
+ if (input.workType) updateData.workType = input.workType as 'HA' | 'HE' | 'HH' | 'HM' | 'HO' | 'HP' | 'NC';
if (input.itemList !== undefined) updateData.itemList = input.itemList;
if (input.subItemList !== undefined) updateData.subItemList = input.subItemList;
if (input.itemCode) updateData.itemCode = input.itemCode;
@@ -1222,6 +1243,7 @@ export async function getOffshoreTopWorkTypes() { { code: 'TS' as OffshoreTopWorkType, name: 'TS'},
{ code: 'TE' as OffshoreTopWorkType, name: 'TE'},
{ code: 'TP' as OffshoreTopWorkType, name: 'TP'},
+ { code: 'TA' as OffshoreTopWorkType, name: 'TA'},
]
}
diff --git a/lib/items-tech/table/add-items-dialog.tsx b/lib/items-tech/table/add-items-dialog.tsx index 1b0d00c7..01a072da 100644 --- a/lib/items-tech/table/add-items-dialog.tsx +++ b/lib/items-tech/table/add-items-dialog.tsx @@ -53,6 +53,7 @@ const offshoreTopWorkTypes = [ { label: "TS", value: "TS" },
{ label: "TE", value: "TE" },
{ label: "TP", value: "TP" },
+ { label: "TA", value: "TA" },
] as const
// 해양 HULL 공종 유형 정의
@@ -68,7 +69,7 @@ const offshoreHullWorkTypes = [ // 기본 아이템 스키마
const itemFormSchema = z.object({
- itemCode: z.string().optional(),
+ itemCode: z.string(),
workType: z.string().min(1, "공종은 필수입니다"),
// 조선 및 해양 아이템 공통 필드
itemList: z.string().optional(),
@@ -126,6 +127,8 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { const onSubmit = async (data: ItemFormValues) => {
startAddTransition(async () => {
try {
+ let result;
+
switch (itemType) {
case 'shipbuilding':
if (!data.shipTypes) {
@@ -133,7 +136,7 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { return
}
- await createShipbuildingItem({
+ result = await createShipbuildingItem({
itemCode: data.itemCode || "",
workType: data.workType as "기장" | "전장" | "선실" | "배관" | "철의" | "선체",
shipTypes: data.shipTypes,
@@ -142,16 +145,16 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { break;
case 'offshoreTop':
- await createOffshoreTopItem({
+ result = await createOffshoreTopItem({
itemCode: data.itemCode || "",
- workType: data.workType as "TM" | "TS" | "TE" | "TP",
+ workType: data.workType as "TM" | "TS" | "TE" | "TP" | "TA",
itemList: data.itemList || null,
subItemList: data.subItemList || null
});
break;
case 'offshoreHull':
- await createOffshoreHullItem({
+ result = await createOffshoreHullItem({
itemCode: data.itemCode || "",
workType: data.workType as "HA" | "HE" | "HH" | "HM" | "HO" | "HP" | "NC",
itemList: data.itemList || null,
@@ -164,10 +167,15 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { return;
}
- toast.success("아이템이 성공적으로 추가되었습니다")
- setOpen(false)
- form.reset(getDefaultValues())
- router.refresh()
+ // 결과 확인하여 성공/실패에 따라 다른 메시지 표시
+ if (result?.success) {
+ toast.success("아이템이 성공적으로 추가되었습니다")
+ setOpen(false)
+ form.reset(getDefaultValues())
+ router.refresh()
+ } else {
+ toast.error(result?.message || "아이템 추가에 실패했습니다")
+ }
} catch (error) {
toast.error("아이템 추가 중 오류가 발생했습니다")
console.error(error)
@@ -225,7 +233,9 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { name="itemCode"
render={({ field }) => (
<FormItem>
- <FormLabel>아이템 코드</FormLabel>
+ <FormLabel>
+ 자재 그룹 <span style={{ color: 'red' }}>*</span>
+ </FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -238,7 +248,7 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { name="workType"
render={({ field }) => (
<FormItem>
- <FormLabel>공종</FormLabel>
+ <FormLabel>공종 <span style={{ color: 'red' }}>*</span></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
@@ -264,7 +274,7 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { name="shipTypes"
render={({ field }) => (
<FormItem>
- <FormLabel>선종</FormLabel>
+ <FormLabel>선종 <span style={{ color: 'red' }}>*</span></FormLabel>
<FormControl>
<Input placeholder="선종을 입력하세요" {...field} />
</FormControl>
@@ -294,7 +304,7 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { name="itemList"
render={({ field }) => (
<FormItem>
- <FormLabel>아이템 리스트</FormLabel>
+ <FormLabel>자재명</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -307,7 +317,7 @@ export function AddItemDialog({ itemType }: AddItemDialogProps) { name="subItemList"
render={({ field }) => (
<FormItem>
- <FormLabel>서브 아이템 리스트</FormLabel>
+ <FormLabel>자재명(상세)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
diff --git a/lib/items-tech/table/hull/offshore-hull-table-columns.tsx b/lib/items-tech/table/hull/offshore-hull-table-columns.tsx index efc6c583..1ad9035c 100644 --- a/lib/items-tech/table/hull/offshore-hull-table-columns.tsx +++ b/lib/items-tech/table/hull/offshore-hull-table-columns.tsx @@ -22,7 +22,7 @@ import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table- interface OffshoreHullTableItem {
id: number;
itemId: number;
- workType: "HA" | "HE" | "HH" | "HM" | "NC";
+ workType: "HA" | "HE" | "HH" | "HM" | "HO" | "HP" | "NC";
itemList: string | null;
subItemList: string | null;
itemCode: string;
diff --git a/lib/items-tech/table/top/import-item-handler.tsx b/lib/items-tech/table/top/import-item-handler.tsx index 4f34cff2..0197d826 100644 --- a/lib/items-tech/table/top/import-item-handler.tsx +++ b/lib/items-tech/table/top/import-item-handler.tsx @@ -4,7 +4,7 @@ import { z } from "zod" import { createOffshoreTopItem } from "../../service"
// 해양 TOP 기능(공종) 유형 enum
-const TOP_WORK_TYPES = ["TM", "TS", "TE", "TP"] as const;
+const TOP_WORK_TYPES = ["TM", "TS", "TE", "TP", "TA"] as const;
// 아이템 데이터 검증을 위한 Zod 스키마
const itemSchema = z.object({
@@ -92,7 +92,7 @@ export async function processTopFileImport( // 해양 TOP 아이템 생성
const result = await createOffshoreTopItem({
itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType as "TM" | "TS" | "TE" | "TP",
+ workType: cleanedRow.workType as "TM" | "TS" | "TE" | "TP" | "TA",
itemList: cleanedRow.itemList,
subItemList: cleanedRow.subItemList,
});
diff --git a/lib/items-tech/table/top/offshore-top-table-columns.tsx b/lib/items-tech/table/top/offshore-top-table-columns.tsx index 93f27492..e1572e0c 100644 --- a/lib/items-tech/table/top/offshore-top-table-columns.tsx +++ b/lib/items-tech/table/top/offshore-top-table-columns.tsx @@ -22,7 +22,7 @@ import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table- interface OffshoreTopTableItem {
id: number;
itemId: number;
- workType: "TM" | "TS" | "TE" | "TP";
+ workType: "TM" | "TS" | "TE" | "TP" | "TA";
itemList: string | null;
subItemList: string | null;
itemCode: string;
diff --git a/lib/items-tech/table/top/offshore-top-table-toolbar-actions.tsx b/lib/items-tech/table/top/offshore-top-table-toolbar-actions.tsx index bf10560f..c81feda0 100644 --- a/lib/items-tech/table/top/offshore-top-table-toolbar-actions.tsx +++ b/lib/items-tech/table/top/offshore-top-table-toolbar-actions.tsx @@ -23,7 +23,7 @@ import { ImportItemButton } from "../import-excel-button" interface OffshoreTopItem {
id: number;
itemId: number;
- workType: "TM" | "TS" | "TE" | "TP";
+ workType: "TM" | "TS" | "TE" | "TP" | "TA";
itemList: string | null;
subItemList: string | null;
itemCode: string;
diff --git a/lib/items-tech/table/top/offshore-top-table.tsx b/lib/items-tech/table/top/offshore-top-table.tsx index c038de13..dc76a06a 100644 --- a/lib/items-tech/table/top/offshore-top-table.tsx +++ b/lib/items-tech/table/top/offshore-top-table.tsx @@ -20,7 +20,7 @@ import { UpdateItemSheet } from "../update-items-sheet" type OffshoreTopItem = {
id: number;
itemId: number;
- workType: "TM" | "TS" | "TE" | "TP";
+ workType: "TM" | "TS" | "TE" | "TP" | "TA";
itemList: string | null;
subItemList: string | null;
itemCode: string;
@@ -74,6 +74,7 @@ export function OffshoreTopTable({ promises }: OffshoreTopTableProps) { { label: "TS", value: "TS" },
{ label: "TE", value: "TE" },
{ label: "TP", value: "TP" },
+ { label: "TA", value: "TA" },
],
},
{
diff --git a/lib/items-tech/table/update-items-sheet.tsx b/lib/items-tech/table/update-items-sheet.tsx index 978e83d5..91108ba0 100644 --- a/lib/items-tech/table/update-items-sheet.tsx +++ b/lib/items-tech/table/update-items-sheet.tsx @@ -52,6 +52,7 @@ const offshoreTopWorkTypes = [ { value: "TS", label: "TS" },
{ value: "TE", label: "TE" },
{ value: "TP", label: "TP" },
+ { value: "TA", label: "TA" },
] as const
const offshoreHullWorkTypes = [
@@ -76,7 +77,7 @@ type ShipbuildingItem = { type OffshoreTopItem = {
id: number
itemCode: string
- workType: "TM" | "TS" | "TE" | "TP"
+ workType: "TM" | "TS" | "TE" | "TP" | "TA"
itemList: string | null
subItemList: string | null
}
@@ -94,6 +95,7 @@ type UpdateItemSchema = { workType?: string
shipTypes?: string
itemList?: string
+ subItemList?: string
}
interface UpdateItemSheetProps {
@@ -125,11 +127,16 @@ export function UpdateItemSheet({ item, itemType, open, onOpenChange }: UpdateIt itemList: (item as ShipbuildingItem).itemList || "",
};
case 'offshoreTop':
+ const offshoreTopItem = item as OffshoreTopItem;
+ return {
+ itemList: offshoreTopItem.itemList || "",
+ subItemList: offshoreTopItem.subItemList || ""
+ };
case 'offshoreHull':
- const offshoreItem = item as OffshoreTopItem | OffshoreHullItem;
+ const offshoreHullItem = item as OffshoreHullItem;
return {
- itemList: offshoreItem.itemList || "",
- subItemList: offshoreItem.subItemList || ""
+ itemList: offshoreHullItem.itemList || "",
+ subItemList: offshoreHullItem.subItemList || ""
};
default:
return {};
@@ -224,7 +231,7 @@ export function UpdateItemSheet({ item, itemType, open, onOpenChange }: UpdateIt <div className="mt-4">
<div className="grid gap-2">
<label className="text-sm font-medium leading-none">
- Material Group (수정 불가)
+ 자재 그룹 (수정 불가)
</label>
<Input value={item.itemCode} disabled readOnly />
</div>
@@ -235,7 +242,7 @@ export function UpdateItemSheet({ item, itemType, open, onOpenChange }: UpdateIt name="workType"
render={({ field }) => (
<FormItem>
- <FormLabel>기능(공종)</FormLabel>
+ <FormLabel>기능(공종) <span style={{ color: 'red' }}>*</span></FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
@@ -266,7 +273,7 @@ export function UpdateItemSheet({ item, itemType, open, onOpenChange }: UpdateIt name="shipTypes"
render={({ field }) => (
<FormItem>
- <FormLabel>선종</FormLabel>
+ <FormLabel>선종 <span style={{ color: 'red' }}>*</span></FormLabel>
<FormControl>
<Input placeholder="선종을 입력하세요" {...field} />
</FormControl>
@@ -281,14 +288,45 @@ export function UpdateItemSheet({ item, itemType, open, onOpenChange }: UpdateIt name="itemList"
render={({ field }) => (
<FormItem>
- <FormLabel>아이템 리스트</FormLabel>
+ <FormLabel>자재명</FormLabel>
<FormControl>
- <Input placeholder="아이템 리스트를 입력하세요" {...field} />
+ <Input placeholder="자재명을 입력하세요" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
+ {itemType === 'offshoreHull' && (
+ <FormField
+ control={form.control}
+ name="subItemList"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>자재명(상세)</FormLabel>
+ <FormControl>
+ <Input placeholder="자재명(상세)을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ {itemType === 'offshoreTop' && (
+ <FormField
+ control={form.control}
+ name="subItemList"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>자재명(상세)</FormLabel>
+ <FormControl>
+ <Input placeholder="자재명(상세)을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
<SheetFooter>
<SheetClose asChild>
<Button variant="outline">취소</Button>
diff --git a/lib/items-tech/validations.ts b/lib/items-tech/validations.ts index 95a34b58..ec662320 100644 --- a/lib/items-tech/validations.ts +++ b/lib/items-tech/validations.ts @@ -107,7 +107,7 @@ export type TypedItemCreateData = ShipbuildingItemCreateData // 해양 TOP 아이템 스키마
export const createOffshoreTopItemSchema = z.object({
itemCode: z.string(),
- workType: z.enum(["TM", "TS", "TE", "TP"]),
+ workType: z.enum(["TM", "TS", "TE", "TP", "TA"]),
itemList: z.string().optional(),
subItemList: z.string().optional(),
})
@@ -126,7 +126,7 @@ export type CreateOffshoreHullItemSchema = z.infer<typeof createOffshoreHullItem // 해양 TOP 아이템 업데이트 스키마
export const updateOffshoreTopItemSchema = z.object({
itemCode: z.string(),
- workType: z.enum(["TM", "TS", "TE", "TP"]).optional(),
+ workType: z.enum(["TM", "TS", "TE", "TP", "TA"]).optional(),
itemList: z.string().optional(),
subItemList: z.string().optional(),
})
@@ -145,7 +145,7 @@ export type UpdateOffshoreHullItemSchema = z.infer<typeof updateOffshoreHullItem // 해양 TOP 아이템 생성 데이터 타입
export interface OffshoreTopItemCreateData {
itemCode: string
- workType: "TM" | "TS" | "TE" | "TP"
+ workType: "TM" | "TS" | "TE" | "TP" | "TA"
itemList?: string | null
subItemList?: string | null
}
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index a5881083..65e23d14 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -2,7 +2,7 @@ import { revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, techVendorItemsView, type TechVendor, techVendorCandidates } from "@/db/schema/techVendors";
+import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, techVendorItemsView, type TechVendor } from "@/db/schema/techVendors";
import { items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
import { users } from "@/db/schema/users";
import ExcelJS from "exceljs";
@@ -1878,22 +1878,12 @@ export async function createTechVendorFromSignup(params: { userId = existingUser.id;
}
- // 6. 후보에서 해당 이메일이 있으면 vendorId 업데이트 및 상태 변경
- if (params.vendorData.email) {
- await tx.update(techVendorCandidates)
- .set({
- vendorId: vendorResult.id,
- status: "INVITED"
- })
- .where(eq(techVendorCandidates.contactEmail, params.vendorData.email));
- }
-
return { vendor: vendorResult, userId };
});
// 캐시 무효화
revalidateTag("tech-vendors");
- revalidateTag("tech-vendor-candidates");
+ revalidateTag("tech-vendor-possible-items");
revalidateTag("users");
console.log("기술영업 벤더 회원가입 완료:", result);
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index fd50b7a6..44537876 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -15,7 +15,7 @@ import { techSalesRfqItems,
biddingProjects
} from "@/db/schema";
-import { and, desc, eq, ilike, or, sql, inArray, count, asc } from "drizzle-orm";
+import { and, desc, eq, ilike, or, sql, inArray, count, asc, lt, ne } from "drizzle-orm";
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
import { getErrorMessage } from "@/lib/handle-error";
@@ -99,6 +99,15 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { return unstable_cache(
async () => {
try {
+ // 마감일이 지났고 아직 Closed가 아닌 RFQ를 일괄 Closed로 변경
+ await db.update(techSalesRfqs)
+ .set({ status: "Closed", updatedAt: new Date() })
+ .where(
+ and(
+ lt(techSalesRfqs.dueDate, new Date()),
+ ne(techSalesRfqs.status, "Closed")
+ )
+ );
const offset = (input.page - 1) * input.perPage;
// 기본 필터 처리 - RFQFilterBox에서 오는 필터
@@ -1387,11 +1396,11 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) { return quotation
})
- // 메일 발송 (백그라운드에서 실행)
- // 선택된 벤더에게 견적 선택 알림 메일 발송
- sendQuotationAcceptedNotification(quotationId).catch(error => {
- console.error("벤더 견적 선택 알림 메일 발송 실패:", error);
- });
+ // // 메일 발송 (백그라운드에서 실행)
+ // // 선택된 벤더에게 견적 선택 알림 메일 발송
+ // sendQuotationAcceptedNotification(quotationId).catch(error => {
+ // console.error("벤더 견적 선택 알림 메일 발송 실패:", error);
+ // });
// 캐시 무효화
revalidateTag("techSalesVendorQuotations")
@@ -3359,9 +3368,15 @@ export async function getAcceptedTechSalesVendorQuotations(input: { const offset = (input.page - 1) * input.perPage;
// 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
- const baseConditions = [
- eq(techSalesVendorQuotations.status, 'Accepted'),
- sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
+ // const baseConditions = [
+ // eq(techSalesVendorQuotations.status, 'Accepted'),
+ // sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
+ // ];
+ // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
+ const baseConditions = [or(
+ eq(techSalesVendorQuotations.status, 'Submitted'),
+ eq(techSalesVendorQuotations.status, 'Accepted')
+ )
];
// 검색 조건 추가
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx index 0a56b702..9411ed02 100644 --- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx +++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx @@ -96,9 +96,10 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) { const rfq = quotation.rfq
const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
- const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
- const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
-
+ // const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+ // const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+ const canSubmit = !["Accepted", "Rejected"].includes(quotation.status)
+ const canEdit = !["Accepted", "Rejected"].includes(quotation.status)
// 파일 업로드 핸들러
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
diff --git a/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx index d90f60b8..4b48407c 100644 --- a/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx +++ b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { useForm, useWatch } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import * as z from "zod" -import { SaveIcon, CheckIcon, XIcon, BarChart3Icon, TrendingUpIcon } from "lucide-react" +import { SaveIcon, CheckIcon, XIcon, BarChart3Icon, TrendingUpIcon, DownloadIcon } from "lucide-react" import { Sheet, @@ -205,6 +205,52 @@ export function EsgEvaluationFormSheet({ } } + // ESG 평가 데이터 내보내기 + const handleExportData = () => { + if (!formData) return + + // CSV 데이터 생성 + const csvData = [] + csvData.push(['카테고리', '점검항목', '평가항목', '평가항목설명', '답변옵션', '옵션점수']) + + formData.evaluations.forEach(evaluation => { + evaluation.items.forEach(item => { + // 각 평가항목에 대해 모든 답변 옵션을 별도 행으로 추가 + item.answerOptions.forEach(option => { + csvData.push([ + evaluation.evaluation.category, + evaluation.evaluation.inspectionItem, + item.item.evaluationItem, + item.item.evaluationItemDescription || '', + option.answerText, + option.score + ]) + }) + }) + }) + + // CSV 문자열 생성 + const csvContent = csvData.map(row => + row.map(field => `"${String(field).replace(/"/g, '""')}"`).join(',') + ).join('\n') + + // BOM 추가 (한글 깨짐 방지) + const bom = '\uFEFF' + const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=utf-8;' }) + + // 다운로드 + const link = document.createElement('a') + const url = URL.createObjectURL(blob) + link.setAttribute('href', url) + link.setAttribute('download', `ESG평가문항_${formData.submission.vendorName}_${new Date().toISOString().split('T')[0]}.csv`) + link.style.visibility = 'hidden' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + toast.success('ESG 평가 문항이 다운로드되었습니다.') + } + // 진행률 및 점수 계산 const getProgress = () => { if (!formData) return { @@ -281,10 +327,23 @@ export function EsgEvaluationFormSheet({ <Sheet open={open} onOpenChange={onOpenChange}> <SheetContent className="w-[900px] sm:max-w-[900px] flex flex-col" style={{width:900, maxWidth:900}}> <SheetHeader> - <SheetTitle>ESG 평가 작성</SheetTitle> - <SheetDescription> - {formData?.submission.vendorName}의 ESG 평가를 작성해주세요. - </SheetDescription> + <div className="flex items-center justify-between"> + <div> + <SheetTitle>ESG 평가 작성</SheetTitle> + <SheetDescription> + {formData?.submission.vendorName}의 ESG 평가를 작성해주세요. + </SheetDescription> + </div> + <Button + variant="outline" + size="sm" + onClick={handleExportData} + className="flex items-center gap-2" + > + <DownloadIcon className="h-4 w-4" /> + 내보내기 + </Button> + </div> </SheetHeader> {formData && ( |
