summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/service.ts230
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx268
-rw-r--r--lib/basic-contract/template/basic-contract-template-columns.tsx41
-rw-r--r--lib/basic-contract/template/basic-contract-template.tsx13
-rw-r--r--lib/basic-contract/template/create-revision-dialog.tsx564
-rw-r--r--lib/basic-contract/template/update-basicContract-sheet.tsx106
-rw-r--r--lib/basic-contract/validations.ts42
-rw-r--r--lib/evaluation-target-list/service.ts267
-rw-r--r--lib/evaluation-target-list/table/delete-targets-dialog.tsx181
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table.tsx199
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-columns.tsx87
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx304
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx32
-rw-r--r--lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx2
-rw-r--r--lib/evaluation-target-list/table/update-evaluation-target.tsx4
-rw-r--r--lib/evaluation-target-list/validation.ts57
-rw-r--r--lib/evaluation/service.ts214
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx455
-rw-r--r--lib/evaluation/table/evaluation-filter-sheet.tsx616
-rw-r--r--lib/evaluation/table/evaluation-table.tsx341
-rw-r--r--lib/evaluation/table/evaluation-view-toggle.tsx88
-rw-r--r--lib/evaluation/validation.ts13
-rw-r--r--lib/gtc-contract/service.ts363
-rw-r--r--lib/gtc-contract/status/create-gtc-document-dialog.tsx272
-rw-r--r--lib/gtc-contract/status/create-new-revision-dialog.tsx157
-rw-r--r--lib/gtc-contract/status/delete-gtc-documents-dialog.tsx168
-rw-r--r--lib/gtc-contract/status/gtc-contract-table.tsx173
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-columns.tsx291
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx90
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx39
-rw-r--r--lib/gtc-contract/status/update-gtc-document-sheet.tsx148
-rw-r--r--lib/gtc-contract/validations.ts89
-rw-r--r--lib/items-tech/service.ts42
-rw-r--r--lib/items-tech/table/add-items-dialog.tsx38
-rw-r--r--lib/items-tech/table/hull/offshore-hull-table-columns.tsx2
-rw-r--r--lib/items-tech/table/top/import-item-handler.tsx4
-rw-r--r--lib/items-tech/table/top/offshore-top-table-columns.tsx2
-rw-r--r--lib/items-tech/table/top/offshore-top-table-toolbar-actions.tsx2
-rw-r--r--lib/items-tech/table/top/offshore-top-table.tsx3
-rw-r--r--lib/items-tech/table/update-items-sheet.tsx56
-rw-r--r--lib/items-tech/validations.ts6
-rw-r--r--lib/tech-vendors/service.ts14
-rw-r--r--lib/techsales-rfq/service.ts33
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx7
-rw-r--r--lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx69
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 && (