summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-13 11:05:09 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-13 11:05:09 +0000
commit33be47506f0aa62b969d82521580a29e95080268 (patch)
tree6b7e232f2d78ef8775944ea085a36b3ccbce7d95 /components
parent2ac95090157c355ea1bd0b8eb1e1e5e2bd56faf4 (diff)
(대표님) 입찰, 법무검토, EDP 변경사항 대응, dolce 개선, form-data 개선, 정규업체 등록관리 추가
(최겸) pq 미사용 컴포넌트 및 페이지 제거, 파일 라우트에 pq 적용
Diffstat (limited to 'components')
-rw-r--r--components/form-data/add-formTag-dialog.tsx4
-rw-r--r--components/form-data/form-data-table.tsx25
-rw-r--r--components/form-data/sedp-compare-dialog.tsx21
-rw-r--r--components/form-data/spreadJS-dialog.tsx1157
-rw-r--r--components/form-data/update-form-sheet.tsx6
-rw-r--r--components/pq-input/pq-input-tabs.tsx2
-rw-r--r--components/pq/client-pq-input-wrapper.tsx90
-rw-r--r--components/pq/pq-input-tabs.tsx884
-rw-r--r--components/pq/pq-review-detail.tsx888
-rw-r--r--components/pq/pq-review-table.tsx344
-rw-r--r--components/pq/project-select-wrapper.tsx35
-rw-r--r--components/pq/project-select.tsx173
-rw-r--r--components/ui/file-list.tsx4
-rw-r--r--components/vendor-regular-registrations/additional-info-dialog.tsx502
-rw-r--r--components/vendor-regular-registrations/document-status-dialog.tsx265
-rw-r--r--components/vendor-regular-registrations/skip-reason-dialog.tsx98
16 files changed, 1539 insertions, 2959 deletions
diff --git a/components/form-data/add-formTag-dialog.tsx b/components/form-data/add-formTag-dialog.tsx
index 9d80de8c..5a462d2b 100644
--- a/components/form-data/add-formTag-dialog.tsx
+++ b/components/form-data/add-formTag-dialog.tsx
@@ -95,6 +95,7 @@ interface AddFormTagDialogProps {
formCode: string;
formName?: string;
contractItemId: number;
+ packageCode: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
@@ -104,6 +105,7 @@ export function AddFormTagDialog({
formCode,
formName,
contractItemId,
+ packageCode,
open: externalOpen,
onOpenChange: externalOnOpenChange
}: AddFormTagDialogProps) {
@@ -361,7 +363,7 @@ export function AddFormTagDialog({
};
try {
- const res = await createTagInForm(tagData, contractItemId, formCode);
+ const res = await createTagInForm(tagData, contractItemId, formCode, packageCode);
if ("error" in res) {
failedTags.push({ tag: row.tagNo, error: res.error });
} else {
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index be37de7a..b2fadacf 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -33,6 +33,8 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import {
+ getPackageCodeById,
+ getProjectById,
getProjectCodeById,
getReportTempList,
sendFormDataToSEDP,
@@ -220,6 +222,8 @@ export default function DynamicTable({
// SEDP compare dialog state
const [sedpCompareOpen, setSedpCompareOpen] = React.useState(false);
const [projectCode, setProjectCode] = React.useState<string>('');
+ const [projectType, setProjectType] = React.useState<string>('plant');
+ const [packageCode, setPackageCode] = React.useState<string>('');
// 새로 추가된 Template 다이얼로그 상태
const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false);
@@ -255,12 +259,26 @@ export default function DynamicTable({
getTempCount();
}, [contractItemId, formId, tempUpDialog]);
+ React.useEffect(() => {
+ const getPackageCode = async () => {
+ try {
+ const packageCode = await getPackageCodeById(contractItemId);
+ setPackageCode(packageCode || ''); // 빈 문자열이나 다른 기본값
+ } catch (error) {
+ console.error('패키지 조회 실패:', error);
+ setPackageCode('');
+ }
+ };
+
+ getPackageCode();
+ }, [contractItemId])
// Get project code when component mounts
React.useEffect(() => {
const getProjectCode = async () => {
try {
- const code = await getProjectCodeById(projectId);
- setProjectCode(code);
+ const project = await getProjectById(projectId);
+ setProjectCode(project.code);
+ setProjectType(project.type);
} catch (error) {
console.error("Error fetching project code:", error);
toast.error("Failed to fetch project code");
@@ -911,6 +929,7 @@ export default function DynamicTable({
formCode={formCode}
formName={`Form ${formCode}`}
contractItemId={contractItemId}
+ packageCode={packageCode}
open={addTagDialogOpen}
onOpenChange={setAddTagDialogOpen}
/>
@@ -982,6 +1001,8 @@ export default function DynamicTable({
projectCode={projectCode}
formCode={formCode}
fetchTagDataFromSEDP={fetchTagDataFromSEDP}
+ projectType={projectType}
+ packageCode={packageCode}
/>
{/* Other dialogs */}
diff --git a/components/form-data/sedp-compare-dialog.tsx b/components/form-data/sedp-compare-dialog.tsx
index 1a9938bd..9a6c8098 100644
--- a/components/form-data/sedp-compare-dialog.tsx
+++ b/components/form-data/sedp-compare-dialog.tsx
@@ -22,6 +22,8 @@ interface SEDPCompareDialogProps {
projectCode: string;
formCode: string;
fetchTagDataFromSEDP: (projectCode: string, formCode: string) => Promise<any>;
+ projectType:string;
+ packageCode:string;
}
interface ComparisonResult {
@@ -67,6 +69,8 @@ export function SEDPCompareDialog({
projectCode,
formCode,
fetchTagDataFromSEDP,
+ projectType,
+ packageCode
}: SEDPCompareDialogProps) {
const params = useParams() || {}
@@ -256,7 +260,22 @@ export function SEDPCompareDialog({
// Create a map of SEDP data by TAG_NO for quick lookup
const sedpTagMap = new Map();
- sedpTagEntries.forEach((entry: any) => {
+
+ const packageCodeAttId = projectType === "ship" ? "CM3003" : "ME5074";
+
+
+ const tagEntries = sedpTagEntries.filter(entry => {
+ if (Array.isArray(entry.ATTRIBUTES)) {
+ const packageCodeAttr = entry.ATTRIBUTES.find(attr => attr.ATT_ID === packageCodeAttId);
+ if (packageCodeAttr && packageCodeAttr.VALUE === packageCode) {
+ return true;
+ }
+ }
+ return false;
+ });
+
+
+ tagEntries.forEach((entry: any) => {
const tagNo = entry.TAG_NO;
const attributesMap = new Map();
diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx
index 11d37911..54a70d9d 100644
--- a/components/form-data/spreadJS-dialog.tsx
+++ b/components/form-data/spreadJS-dialog.tsx
@@ -13,7 +13,6 @@ import { Loader, Save, AlertTriangle } from "lucide-react";
import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css';
import { DataTableColumnJSON, ColumnType } from "./form-data-table-columns";
-// SpreadSheets를 동적으로 import (SSR 비활성화)
const SpreadSheets = dynamic(
() => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets),
{
@@ -27,7 +26,6 @@ const SpreadSheets = dynamic(
}
);
-// 라이센스 키 설정을 클라이언트에서만 실행
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) {
GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE;
}
@@ -88,8 +86,8 @@ interface TemplateViewDialogProps {
isOpen: boolean;
onClose: () => void;
templateData: TemplateItem[] | any;
- selectedRow?: GenericData; // SPREAD_ITEM용
- tableData?: GenericData[]; // SPREAD_LIST용
+ selectedRow?: GenericData;
+ tableData?: GenericData[];
formCode: string;
columnsJSON: DataTableColumnJSON[]
contractItemId: number;
@@ -97,6 +95,44 @@ interface TemplateViewDialogProps {
onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void;
}
+// 🚀 로딩 프로그레스 컴포넌트
+interface LoadingProgressProps {
+ phase: string;
+ progress: number;
+ total: number;
+ isVisible: boolean;
+}
+
+const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, total, isVisible }) => {
+ const percentage = total > 0 ? Math.round((progress / total) * 100) : 0;
+
+ if (!isVisible) return null;
+
+ return (
+ <div className="absolute inset-0 bg-white/90 flex items-center justify-center z-50">
+ <div className="bg-white rounded-lg shadow-lg border p-6 min-w-[300px]">
+ <div className="flex items-center space-x-3 mb-4">
+ <Loader className="h-5 w-5 animate-spin text-blue-600" />
+ <span className="font-medium text-gray-900">Loading Template</span>
+ </div>
+
+ <div className="space-y-2">
+ <div className="text-sm text-gray-600">{phase}</div>
+ <div className="w-full bg-gray-200 rounded-full h-2">
+ <div
+ className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
+ style={{ width: `${percentage}%` }}
+ />
+ </div>
+ <div className="text-xs text-gray-500 text-right">
+ {progress.toLocaleString()} / {total.toLocaleString()} ({percentage}%)
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
export function TemplateViewDialog({
isOpen,
onClose,
@@ -123,13 +159,41 @@ export function TemplateViewDialog({
const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>("");
const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]);
+
+ // 🆕 로딩 상태 추가
+ const [loadingProgress, setLoadingProgress] = React.useState<{
+ phase: string;
+ progress: number;
+ total: number;
+ } | null>(null);
+ const [isInitializing, setIsInitializing] = React.useState(false);
+
+ // 🔄 진행상황 업데이트 함수
+ const updateProgress = React.useCallback((phase: string, progress: number, total: number) => {
+ setLoadingProgress({ phase, progress, total });
+ }, []);
+
+ const determineTemplateType = React.useCallback((template: TemplateItem): 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null => {
+ if (template.TMPL_TYPE === "SPREAD_LIST" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) {
+ return 'SPREAD_LIST';
+ }
+ if (template.TMPL_TYPE === "SPREAD_ITEM" && (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT)) {
+ return 'SPREAD_ITEM';
+ }
+ if (template.GRD_LST_SETUP && columnsJSON.length > 0) {
+ return 'GRD_LIST';
+ }
+ return null;
+ }, [columnsJSON]);
+
+ const isValidTemplate = React.useCallback((template: TemplateItem): boolean => {
+ return determineTemplateType(template) !== null;
+ }, [determineTemplateType]);
- // 클라이언트 사이드에서만 렌더링되도록 보장
React.useEffect(() => {
setIsClient(true);
}, []);
- // 사용 가능한 템플릿들을 필터링하고 설정
React.useEffect(() => {
if (!templateData) return;
@@ -140,134 +204,158 @@ export function TemplateViewDialog({
templates = [templateData as TemplateItem];
}
- // 유효한 템플릿들 필터링
- const validTemplates = templates.filter(template => {
- const hasSpreadListContent = template.SPR_LST_SETUP?.CONTENT;
- const hasSpreadItemContent = template.SPR_ITM_LST_SETUP?.CONTENT;
- const hasGrdListSetup = template.GRD_LST_SETUP && columnsJSON.length > 0; // GRD_LIST 조건: GRD_LST_SETUP 존재 + columnsJSON 있음
-
- const isValidType = template.TMPL_TYPE === "SPREAD_LIST" ||
- template.TMPL_TYPE === "SPREAD_ITEM" ||
- template.TMPL_TYPE === "GRD_LIST"; // GRD_LIST 타입 추가
-
- return isValidType && (hasSpreadListContent || hasSpreadItemContent || hasGrdListSetup);
- });
-
+ const validTemplates = templates.filter(isValidTemplate);
setAvailableTemplates(validTemplates);
- // 첫 번째 유효한 템플릿을 기본으로 선택
if (validTemplates.length > 0 && !selectedTemplateId) {
const firstTemplate = validTemplates[0];
+ const templateTypeToSet = determineTemplateType(firstTemplate);
setSelectedTemplateId(firstTemplate.TMPL_ID);
-
- // 템플릿 타입 결정
- let templateTypeToSet: 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST';
- if (firstTemplate.GRD_LST_SETUP && columnsJSON.length > 0) {
- templateTypeToSet = 'GRD_LIST';
- } else {
- templateTypeToSet = firstTemplate.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM';
- }
-
setTemplateType(templateTypeToSet);
}
- }, [templateData, selectedTemplateId]);
+ }, [templateData, selectedTemplateId, isValidTemplate, determineTemplateType]);
- // 선택된 템플릿 변경 처리
const handleTemplateChange = (templateId: string) => {
const template = availableTemplates.find(t => t.TMPL_ID === templateId);
if (template) {
+ const templateTypeToSet = determineTemplateType(template);
setSelectedTemplateId(templateId);
-
- // 템플릿 타입 결정
- let templateTypeToSet: 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST';
- if (template.GRD_LST_SETUP && columnsJSON.length > 0) {
- templateTypeToSet = 'GRD_LIST';
- } else {
- templateTypeToSet = template.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM';
- }
-
setTemplateType(templateTypeToSet);
setHasChanges(false);
setValidationErrors([]);
- // SpreadSheets 재초기화
- if (currentSpread) {
- const template = availableTemplates.find(t => t.TMPL_ID === templateId);
- if (template) {
- initSpread(currentSpread, template);
- }
+ if (currentSpread && template) {
+ initSpread(currentSpread, template);
}
}
};
- // 현재 선택된 템플릿 가져오기
const selectedTemplate = React.useMemo(() => {
return availableTemplates.find(t => t.TMPL_ID === selectedTemplateId);
}, [availableTemplates, selectedTemplateId]);
- // 편집 가능한 필드 목록 계산
const editableFields = React.useMemo(() => {
- // SPREAD_ITEM인 경우: selectedRow의 TAG_NO로 확인
if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) {
if (!editableFieldsMap.has(selectedRow.TAG_NO)) {
return [];
}
return editableFieldsMap.get(selectedRow.TAG_NO) || [];
}
-
- // SPREAD_LIST 또는 GRD_LIST인 경우: 첫 번째 행의 TAG_NO를 기준으로 처리
+
if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) {
const firstRowTagNo = tableData[0]?.TAG_NO;
if (firstRowTagNo && editableFieldsMap.has(firstRowTagNo)) {
return editableFieldsMap.get(firstRowTagNo) || [];
}
}
-
+
return [];
}, [templateType, selectedRow?.TAG_NO, tableData, editableFieldsMap]);
- // 필드가 편집 가능한지 판별하는 함수
const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => {
- // columnsJSON에서 해당 attId의 shi 값 확인
const columnConfig = columnsJSON.find(col => col.key === attId);
if (columnConfig?.shi === true) {
- return false; // columnsJSON에서 shi가 true이면 편집 불가
+ return false;
}
- // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (columnsJSON의 shi가 false인 경우)
- if (attId === "TAG_NO" || attId === "TAG_DESC") {
- return true;
+ if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") {
+ return false;
}
- // SPREAD_ITEM인 경우: editableFields 체크
- if (templateType === 'SPREAD_ITEM') {
- return editableFields.includes(attId);
- }
-
- // SPREAD_LIST 또는 GRD_LIST인 경우: 개별 행의 편집 가능성도 고려
if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
- // 기본적으로 editableFields에 포함되어야 함
- if (!editableFields.includes(attId)) {
- return false;
- }
-
- // rowData가 제공된 경우 해당 행의 shi 상태도 확인
if (rowData && rowData.shi === true) {
return false;
}
-
return true;
}
-
+
return true;
- }, [templateType, columnsJSON, editableFields]);
+ }, [templateType, columnsJSON]);
- // 편집 가능한 필드 개수 계산
const editableFieldsCount = React.useMemo(() => {
return cellMappings.filter(m => m.isEditable).length;
}, [cellMappings]);
- // 셀 주소를 행과 열로 변환하는 함수
+ // 🚀 배치 처리 함수들
+ const setBatchValues = React.useCallback((
+ activeSheet: any,
+ valuesToSet: Array<{row: number, col: number, value: any}>
+ ) => {
+ console.log(`🚀 Setting ${valuesToSet.length} values in batch`);
+
+ const columnGroups = new Map<number, Array<{row: number, value: any}>>();
+
+ valuesToSet.forEach(({row, col, value}) => {
+ if (!columnGroups.has(col)) {
+ columnGroups.set(col, []);
+ }
+ columnGroups.get(col)!.push({row, value});
+ });
+
+ columnGroups.forEach((values, col) => {
+ values.sort((a, b) => a.row - b.row);
+
+ let start = 0;
+ while (start < values.length) {
+ let end = start;
+ while (end + 1 < values.length && values[end + 1].row === values[end].row + 1) {
+ end++;
+ }
+
+ const rangeValues = values.slice(start, end + 1).map(v => v.value);
+ const startRow = values[start].row;
+
+ try {
+ if (rangeValues.length === 1) {
+ activeSheet.setValue(startRow, col, rangeValues[0]);
+ } else {
+ const dataArray = rangeValues.map(v => [v]);
+ activeSheet.setArray(startRow, col, dataArray);
+ }
+ } catch (error) {
+ for (let i = start; i <= end; i++) {
+ try {
+ activeSheet.setValue(values[i].row, col, values[i].value);
+ } catch (cellError) {
+ console.warn(`⚠️ Individual value setting failed [${values[i].row}, ${col}]:`, cellError);
+ }
+ }
+ }
+
+ start = end + 1;
+ }
+ });
+ }, []);
+
+ const setBatchStyles = React.useCallback((
+ activeSheet: any,
+ stylesToSet: Array<{row: number, col: number, isEditable: boolean}>
+ ) => {
+ console.log(`🎨 Setting ${stylesToSet.length} styles in batch`);
+
+ const editableStyle = createCellStyle(true);
+ const readonlyStyle = createCellStyle(false);
+
+ // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장)
+ stylesToSet.forEach(({row, col, isEditable}) => {
+ try {
+ const cell = activeSheet.getCell(row, col);
+ const style = isEditable ? editableStyle : readonlyStyle;
+
+ activeSheet.setStyle(row, col, style);
+ cell.locked(!isEditable); // 편집 가능하면 잠금 해제
+
+ // 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정
+ if (isEditable) {
+ const textCellType = new GC.Spread.Sheets.CellTypes.Text();
+ activeSheet.setCellType(row, col, textCellType);
+ }
+ } catch (error) {
+ console.warn(`⚠️ Failed to set style for cell [${row}, ${col}]:`, error);
+ }
+ });
+ }, [createCellStyle]);
+
const parseCellAddress = (address: string): { row: number, col: number } | null => {
if (!address || address.trim() === "") return null;
@@ -283,11 +371,9 @@ export function TemplateViewDialog({
col -= 1;
const row = parseInt(rowStr) - 1;
-
return { row, col };
};
- // 행과 열을 셀 주소로 변환하는 함수 (GRD_LIST용)
const getCellAddress = (row: number, col: number): string => {
let colStr = '';
let colNum = col;
@@ -298,10 +384,9 @@ export function TemplateViewDialog({
return colStr + (row + 1);
};
- // 데이터 타입 검증 함수
const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => {
if (value === undefined || value === null || value === "") {
- return null; // 빈 값은 별도 required 검증에서 처리
+ return null;
}
switch (columnType) {
@@ -316,17 +401,14 @@ export function TemplateViewDialog({
}
break;
case "STRING":
- // STRING 타입은 대부분의 값을 허용
break;
default:
- // 커스텀 타입의 경우 추가 검증 로직이 필요할 수 있음
break;
}
return null;
};
- // 전체 데이터 검증 함수
const validateAllData = React.useCallback(() => {
if (!currentSpread || !selectedTemplate) return [];
@@ -340,68 +422,44 @@ export function TemplateViewDialog({
const cellPos = parseCellAddress(mapping.cellAddress);
if (!cellPos) return;
- if (templateType === 'SPREAD_ITEM') {
- // 단일 행 검증
- const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
- const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options);
-
- if (errorMessage) {
- errors.push({
- cellAddress: mapping.cellAddress,
- attId: mapping.attId,
- value: cellValue,
- expectedType: columnConfig.type,
- message: errorMessage
- });
- }
- } else if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
- // 복수 행 검증 - 각 매핑은 이미 개별 행을 가리킴
- const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
- const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options);
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options);
- if (errorMessage) {
- errors.push({
- cellAddress: mapping.cellAddress,
- attId: mapping.attId,
- value: cellValue,
- expectedType: columnConfig.type,
- message: errorMessage
- });
- }
+ if (errorMessage) {
+ errors.push({
+ cellAddress: mapping.cellAddress,
+ attId: mapping.attId,
+ value: cellValue,
+ expectedType: columnConfig.type,
+ message: errorMessage
+ });
}
});
setValidationErrors(errors);
return errors;
- }, [currentSpread, selectedTemplate, cellMappings, columnsJSON, templateType]);
-
- // ═══════════════════════════════════════════════════════════════════════════════
- // 🛠️ 헬퍼 함수들
- // ═══════════════════════════════════════════════════════════════════════════════
+ }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]);
- // 🎨 셀 스타일 생성
const createCellStyle = React.useCallback((isEditable: boolean) => {
const style = new GC.Spread.Sheets.Style();
if (isEditable) {
- style.backColor = "#f0fdf4"; // 연한 초록 (편집 가능)
+ style.backColor = "#f0fdf4";
} else {
- style.backColor = "#f9fafb"; // 연한 회색 (읽기 전용)
+ style.backColor = "#f9fafb";
style.foreColor = "#6b7280";
}
return style;
}, []);
- // 🎯 드롭다운 설정
const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => {
try {
console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options);
- // ✅ options 정규화
const safeOptions = options
- .filter(opt => opt !== null && opt !== undefined && opt !== '')
+ .filter(opt => opt !== null && opt !== undefined && opt !== '')
.map(opt => String(opt).trim())
.filter(opt => opt.length > 0)
- .filter((opt, index, arr) => arr.indexOf(opt) === index)
+ .filter((opt, index, arr) => arr.indexOf(opt) === index)
.slice(0, 20);
if (safeOptions.length === 0) {
@@ -409,280 +467,333 @@ export function TemplateViewDialog({
return;
}
- console.log(`📋 Safe options:`, safeOptions);
-
- // ✅ DataValidation용 문자열 준비
const optionsString = safeOptions.join(',');
- // 🔑 핵심 수정: 각 셀마다 개별 ComboBox 인스턴스 생성!
for (let i = 0; i < rowCount; i++) {
try {
const targetRow = cellPos.row + i;
- // ✅ 각 셀마다 새로운 ComboBox 인스턴스 생성
+ // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성
const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
- comboBoxCellType.items(safeOptions); // 배열로 전달
+ comboBoxCellType.items(safeOptions);
comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
-
- // ✅ 각 셀마다 새로운 DataValidation 인스턴스 생성
+
+ // 🔧 DataValidation 설정
const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString);
-
- // ComboBox + DataValidation 둘 다 적용
+ cellValidator.showInputMessage(false);
+ cellValidator.showErrorMessage(false);
+
+ // ComboBox와 Validator 적용
activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType);
activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator);
- // 셀 잠금 해제
+ // 🚀 중요: 셀 잠금 해제 및 편집 가능 설정
const cell = activeSheet.getCell(targetRow, cellPos.col);
cell.locked(false);
-
- console.log(`✅ Individual dropdown applied to [${targetRow}, ${cellPos.col}]`);
-
+
+ console.log(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`);
+
} catch (cellError) {
- console.warn(`⚠️ Failed to apply to row ${cellPos.row + i}:`, cellError);
+ console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError);
}
}
- console.log(`✅ Safe dropdown setup completed for ${rowCount} cells`);
+ console.log(`✅ Dropdown setup completed for ${rowCount} cells`);
} catch (error) {
console.error('❌ Dropdown setup failed:', error);
}
}, []);
- // 🚀 행 용량 확보
+ const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => {
+ if (!spread) return null;
+
+ try {
+ let activeSheet = spread.getActiveSheet();
+ if (!activeSheet) {
+ const sheetCount = spread.getSheetCount();
+ if (sheetCount > 0) {
+ activeSheet = spread.getSheet(0);
+ if (activeSheet) {
+ spread.setActiveSheetIndex(0);
+ }
+ }
+ }
+ return activeSheet;
+ } catch (error) {
+ console.error(`❌ Error getting activeSheet in ${functionName}:`, error);
+ return null;
+ }
+ }, []);
+
const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => {
- const currentRowCount = activeSheet.getRowCount();
- if (requiredRowCount > currentRowCount) {
- activeSheet.setRowCount(requiredRowCount + 10); // 여유분 추가
- console.log(`📈 Expanded sheet to ${requiredRowCount + 10} rows`);
+ try {
+ if (!activeSheet) return false;
+
+ const currentRowCount = activeSheet.getRowCount();
+ if (requiredRowCount > currentRowCount) {
+ const newRowCount = requiredRowCount + 10;
+ activeSheet.setRowCount(newRowCount);
+ }
+ return true;
+ } catch (error) {
+ console.error('❌ Error in ensureRowCapacity:', error);
+ return false;
}
}, []);
- // 🆕 GRD_LIST용 동적 테이블 생성 함수
- const createGrdListTable = React.useCallback((activeSheet: any, template: TemplateItem) => {
- console.log('🏗️ Creating GRD_LIST table');
-
- // columnsJSON의 visible 컬럼들을 seq 순서로 정렬하여 사용
- const visibleColumns = columnsJSON
- .filter(col => col.hidden !== true) // hidden이 true가 아닌 것들만
- .sort((a, b) => {
- // seq가 없는 경우 999999로 처리하여 맨 뒤로 보냄
- const seqA = a.seq !== undefined ? a.seq : 999999;
- const seqB = b.seq !== undefined ? b.seq : 999999;
- return seqA - seqB;
- });
+ const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => {
+ try {
+ if (!activeSheet) return false;
+
+ const currentColumnCount = activeSheet.getColumnCount();
+ if (requiredColumnCount > currentColumnCount) {
+ const newColumnCount = requiredColumnCount + 10;
+ activeSheet.setColumnCount(newColumnCount);
+ }
+ return true;
+ } catch (error) {
+ console.error('❌ Error in ensureColumnCapacity:', error);
+ return false;
+ }
+ }, []);
- console.log('📊 Using columns:', visibleColumns.map(c => `${c.key}(seq:${c.seq})`));
+ const setOptimalColumnWidths = React.useCallback((activeSheet: any, columns: any[], startCol: number, tableData: any[]) => {
+ columns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+ const optimalWidth = column.type === 'NUMBER' ? 100 : column.type === 'STRING' ? 150 : 120;
+ activeSheet.setColumnWidth(targetCol, optimalWidth);
+ });
+ }, []);
- if (visibleColumns.length === 0) {
- console.warn('❌ No visible columns found in columnsJSON');
- return [];
- }
+ // 🚀 최적화된 GRD_LIST 생성
+ const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => {
+ console.log('🚀 Creating optimized GRD_LIST table');
- // 테이블 생성 시작
+ const visibleColumns = columnsJSON
+ .filter(col => col.hidden !== true)
+ .sort((a, b) => (a.seq ?? 999999) - (b.seq ?? 999999));
+
+ if (visibleColumns.length === 0) return [];
+
+ const startCol = 1;
+ const dataStartRow = 1;
const mappings: CellMapping[] = [];
- const startCol = 1; // A열 제외하고 B열부터 시작
-
- // 🔍 그룹 헤더 분석
- const groupInfo = analyzeColumnGroups(visibleColumns);
- const hasGroups = groupInfo.groups.length > 0;
-
- // 헤더 행 계산: 그룹이 있으면 2행, 없으면 1행
- const groupHeaderRow = 0;
- const columnHeaderRow = hasGroups ? 1 : 0;
- const dataStartRow = hasGroups ? 2 : 1;
-
- // 🎨 헤더 스타일 생성
- const groupHeaderStyle = new GC.Spread.Sheets.Style();
- groupHeaderStyle.backColor = "#1e40af"; // 더 진한 파란색
- groupHeaderStyle.foreColor = "#ffffff";
- groupHeaderStyle.font = "bold 13px Arial";
- groupHeaderStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center;
- groupHeaderStyle.vAlign = GC.Spread.Sheets.VerticalAlign.center;
-
- const columnHeaderStyle = new GC.Spread.Sheets.Style();
- columnHeaderStyle.backColor = "#3b82f6"; // 기본 파란색
- columnHeaderStyle.foreColor = "#ffffff";
- columnHeaderStyle.font = "bold 12px Arial";
- columnHeaderStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center;
- columnHeaderStyle.vAlign = GC.Spread.Sheets.VerticalAlign.center;
-
- let currentCol = startCol;
-
- // 🏗️ 그룹 헤더 및 컬럼 헤더 생성
- if (hasGroups) {
- // 그룹 헤더가 있는 경우
- groupInfo.groups.forEach(group => {
- if (group.isGroup) {
- // 그룹 헤더 생성 및 병합
- const groupStartCol = currentCol;
- const groupEndCol = currentCol + group.columns.length - 1;
-
- // 그룹 헤더 셀 설정
- const groupHeaderCell = activeSheet.getCell(groupHeaderRow, groupStartCol);
- groupHeaderCell.value(group.head);
-
- // 그룹 헤더 병합
- if (group.columns.length > 1) {
- activeSheet.addSpan(groupHeaderRow, groupStartCol, 1, group.columns.length);
- }
-
- // 그룹 헤더 스타일 적용
- for (let col = groupStartCol; col <= groupEndCol; col++) {
- activeSheet.setStyle(groupHeaderRow, col, groupHeaderStyle);
- activeSheet.getCell(groupHeaderRow, col).locked(true);
- }
-
- console.log(`📝 Group Header [${groupHeaderRow}, ${groupStartCol}-${groupEndCol}]: ${group.head}`);
-
- // 그룹 내 개별 컬럼 헤더 생성
- group.columns.forEach((column, index) => {
- const colIndex = groupStartCol + index;
- const columnHeaderCell = activeSheet.getCell(columnHeaderRow, colIndex);
- columnHeaderCell.value(column.label);
- activeSheet.setStyle(columnHeaderRow, colIndex, columnHeaderStyle);
- columnHeaderCell.locked(true);
-
- console.log(`📝 Column Header [${columnHeaderRow}, ${colIndex}]: ${column.label}`);
- });
-
- currentCol += group.columns.length;
- } else {
- // 그룹이 아닌 단일 컬럼
- const column = group.columns[0];
-
- // 그룹 헤더 행에는 빈 셀 (개별 컬럼이므로)
- const groupHeaderCell = activeSheet.getCell(groupHeaderRow, currentCol);
- groupHeaderCell.value("");
- activeSheet.setStyle(groupHeaderRow, currentCol, groupHeaderStyle);
- groupHeaderCell.locked(true);
-
- // 컬럼 헤더 생성
- const columnHeaderCell = activeSheet.getCell(columnHeaderRow, currentCol);
- columnHeaderCell.value(column.label);
- activeSheet.setStyle(columnHeaderRow, currentCol, columnHeaderStyle);
- columnHeaderCell.locked(true);
-
- console.log(`📝 Single Column [${columnHeaderRow}, ${currentCol}]: ${column.label}`);
- currentCol++;
- }
- });
- } else {
- // 그룹이 없는 경우 - 기존 로직
- visibleColumns.forEach((column, colIndex) => {
- const headerCol = startCol + colIndex;
- const headerCell = activeSheet.getCell(columnHeaderRow, headerCol);
- headerCell.value(column.label);
- activeSheet.setStyle(columnHeaderRow, headerCol, columnHeaderStyle);
- headerCell.locked(true);
-
- console.log(`📝 Header [${columnHeaderRow}, ${headerCol}]: ${column.label}`);
- });
- }
- // 데이터 행 생성
- const dataRowCount = tableData.length;
- ensureRowCapacity(activeSheet, dataStartRow + dataRowCount);
+ ensureColumnCapacity(activeSheet, startCol + visibleColumns.length);
+ ensureRowCapacity(activeSheet, dataStartRow + tableData.length);
+
+ // 헤더 생성
+ const headerStyle = new GC.Spread.Sheets.Style();
+ headerStyle.backColor = "#3b82f6";
+ headerStyle.foreColor = "#ffffff";
+ headerStyle.font = "bold 12px Arial";
+ headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center;
+
+ visibleColumns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+ const cell = activeSheet.getCell(0, targetCol);
+ cell.value(column.label);
+ cell.locked(true);
+ activeSheet.setStyle(0, targetCol, headerStyle);
+ });
+
+ // 🚀 데이터 배치 처리 준비
+ const allValues: Array<{row: number, col: number, value: any}> = [];
+ const allStyles: Array<{row: number, col: number, isEditable: boolean}> = [];
+
+ // 🔧 편집 가능한 셀 정보 수집 (드롭다운용)
+ const dropdownConfigs: Array<{
+ startRow: number;
+ col: number;
+ rowCount: number;
+ options: string[];
+ editableRows: number[]; // 편집 가능한 행만 추적
+ }> = [];
- tableData.forEach((rowData, rowIndex) => {
- const targetRow = dataStartRow + rowIndex;
+ visibleColumns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
- visibleColumns.forEach((column, colIndex) => {
- const targetCol = startCol + colIndex;
- const cellAddress = getCellAddress(targetRow, targetCol);
- const isEditable = isFieldEditable(column.key, rowData);
+ // 드롭다운 설정을 위한 편집 가능한 행 찾기
+ if (column.type === "LIST" && column.options) {
+ const editableRows: number[] = [];
+ tableData.forEach((rowData, rowIndex) => {
+ if (isFieldEditable(column.key, rowData)) {
+ editableRows.push(dataStartRow + rowIndex);
+ }
+ });
- // 매핑 정보 추가
+ if (editableRows.length > 0) {
+ dropdownConfigs.push({
+ startRow: dataStartRow,
+ col: targetCol,
+ rowCount: tableData.length,
+ options: column.options,
+ editableRows: editableRows
+ });
+ }
+ }
+
+ tableData.forEach((rowData, rowIndex) => {
+ const targetRow = dataStartRow + rowIndex;
+ const cellEditable = isFieldEditable(column.key, rowData);
+ const value = rowData[column.key];
+
mappings.push({
attId: column.key,
- cellAddress: cellAddress,
- isEditable: isEditable,
+ cellAddress: getCellAddress(targetRow, targetCol),
+ isEditable: cellEditable,
dataRowIndex: rowIndex
});
- // 셀 값 설정
- const cell = activeSheet.getCell(targetRow, targetCol);
- const value = rowData[column.key];
- cell.value(value ?? null);
-
- // 스타일 적용
- const style = createCellStyle(isEditable);
- activeSheet.setStyle(targetRow, targetCol, style);
- cell.locked(!isEditable);
-
- console.log(`📝 Data [${targetRow}, ${targetCol}]: ${column.key} = "${value}" (${isEditable ? 'Editable' : 'ReadOnly'})`);
+ allValues.push({
+ row: targetRow,
+ col: targetCol,
+ value: value ?? null
+ });
- // LIST 타입 드롭다운 설정
- if (column.type === "LIST" && column.options && isEditable) {
- setupOptimizedListValidation(activeSheet, { row: targetRow, col: targetCol }, column.options, 1);
- }
+ allStyles.push({
+ row: targetRow,
+ col: targetCol,
+ isEditable: cellEditable
+ });
});
});
- console.log(`🏗️ GRD_LIST table created with ${mappings.length} mappings, hasGroups: ${hasGroups}`);
- return mappings;
- }, [tableData, columnsJSON, isFieldEditable, createCellStyle, ensureRowCapacity, setupOptimizedListValidation]);
-
- // 🔍 컬럼 그룹 분석 함수
- const analyzeColumnGroups = React.useCallback((columns: DataTableColumnJSON[]) => {
- const groups: Array<{
- head: string;
- isGroup: boolean;
- columns: DataTableColumnJSON[];
- }> = [];
+ // 🚀 배치로 값과 스타일 설정
+ setBatchValues(activeSheet, allValues);
+ setBatchStyles(activeSheet, allStyles);
- let i = 0;
- while (i < columns.length) {
- const currentCol = columns[i];
-
- // head가 없거나 빈 문자열인 경우 단일 컬럼으로 처리
- if (!currentCol.head || !currentCol.head.trim()) {
- groups.push({
- head: '',
- isGroup: false,
- columns: [currentCol]
+ // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만)
+ dropdownConfigs.forEach(({ col, options, editableRows }) => {
+ try {
+ console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`);
+
+ const safeOptions = options
+ .filter(opt => opt !== null && opt !== undefined && opt !== '')
+ .map(opt => String(opt).trim())
+ .filter(opt => opt.length > 0)
+ .slice(0, 20);
+
+ if (safeOptions.length === 0) return;
+
+ // 편집 가능한 행에만 드롭다운 적용
+ editableRows.forEach(targetRow => {
+ try {
+ const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBoxCellType.items(safeOptions);
+ comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+
+ const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(','));
+ cellValidator.showInputMessage(false);
+ cellValidator.showErrorMessage(false);
+
+ activeSheet.setCellType(targetRow, col, comboBoxCellType);
+ activeSheet.setDataValidator(targetRow, col, cellValidator);
+
+ // 🚀 편집 권한 명시적 설정
+ const cell = activeSheet.getCell(targetRow, col);
+ cell.locked(false);
+
+ console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`);
+ } catch (cellError) {
+ console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError);
+ }
});
- i++;
- continue;
+ } catch (error) {
+ console.error(`❌ Dropdown config failed for column ${col}:`, error);
}
+ });
- // 같은 head를 가진 연속된 컬럼들을 찾기
- const groupHead = currentCol.head.trim();
- const groupColumns: DataTableColumnJSON[] = [currentCol];
- let j = i + 1;
-
- while (j < columns.length && columns[j].head && columns[j].head.trim() === groupHead) {
- groupColumns.push(columns[j]);
- j++;
- }
+ setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData);
+
+ console.log(`✅ Optimized GRD_LIST created:`);
+ console.log(` - Total mappings: ${mappings.length}`);
+ console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`);
+ console.log(` - Dropdown configs: ${dropdownConfigs.length}`);
+
+ return mappings;
+ }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]);
- // 그룹 추가
- groups.push({
- head: groupHead,
- isGroup: groupColumns.length > 1,
- columns: groupColumns
- });
+ const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => {
+ console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`);
- i = j; // 다음 그룹으로 이동
- }
+ // 🔧 시트 보호 완전 해제 후 편집 권한 설정
+ activeSheet.options.isProtected = false;
- return { groups };
- }, []);
+ // 🔧 편집 가능한 셀들을 위한 강화된 설정
+ mappings.forEach((mapping) => {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (!cellPos) return;
+
+ try {
+ const cell = activeSheet.getCell(cellPos.row, cellPos.col);
+ const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
+
+ if (mapping.isEditable) {
+ // 🚀 편집 가능한 셀 설정 강화
+ cell.locked(false);
+
+ if (columnConfig?.type === "LIST" && columnConfig.options) {
+ // LIST 타입: 새 ComboBox 인스턴스 생성
+ const comboBox = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBox.items(columnConfig.options);
+ comboBox.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+ activeSheet.setCellType(cellPos.row, cellPos.col, comboBox);
+
+ // DataValidation도 추가
+ const validator = GC.Spread.Sheets.DataValidation.createListValidator(columnConfig.options.join(','));
+ activeSheet.setDataValidator(cellPos.row, cellPos.col, validator);
+ } else if (columnConfig?.type === "NUMBER") {
+ // NUMBER 타입: 숫자 입력 허용
+ const textCellType = new GC.Spread.Sheets.CellTypes.Text();
+ activeSheet.setCellType(cellPos.row, cellPos.col, textCellType);
+
+ // 숫자 validation 추가 (에러 메시지 없이)
+ const numberValidator = GC.Spread.Sheets.DataValidation.createNumberValidator(
+ GC.Spread.Sheets.ConditionalFormatting.ComparisonOperators.between,
+ -999999999, 999999999, true
+ );
+ numberValidator.showInputMessage(false);
+ numberValidator.showErrorMessage(false);
+ activeSheet.setDataValidator(cellPos.row, cellPos.col, numberValidator);
+ } else {
+ // 기본 TEXT 타입: 자유 텍스트 입력
+ const textCellType = new GC.Spread.Sheets.CellTypes.Text();
+ activeSheet.setCellType(cellPos.row, cellPos.col, textCellType);
+ }
+
+ // 편집 가능 스타일 재적용
+ const editableStyle = createCellStyle(true);
+ activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle);
+
+ console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`);
+ } else {
+ // 읽기 전용 셀
+ cell.locked(true);
+ const readonlyStyle = createCellStyle(false);
+ activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle);
+ }
+ } catch (error) {
+ console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error);
+ }
+ });
- // 🛡️ 시트 보호 및 이벤트 설정
- const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => {
- console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`);
-
- // 시트 보호 설정
+ // 🛡️ 시트 보호 재설정 (편집 허용 모드로)
activeSheet.options.isProtected = true;
activeSheet.options.protectionOptions = {
allowSelectLockedCells: true,
allowSelectUnlockedCells: true,
allowSort: false,
allowFilter: false,
- allowEditObjects: false,
+ allowEditObjects: true, // ✅ 편집 객체 허용
allowResizeRows: false,
- allowResizeColumns: false
+ allowResizeColumns: false,
+ allowFormatCells: false,
+ allowInsertRows: false,
+ allowInsertColumns: false,
+ allowDeleteRows: false,
+ allowDeleteColumns: false
};
// 🎯 변경 감지 이벤트
@@ -699,11 +810,10 @@ export function TemplateViewDialog({
});
});
- // 🚫 편집 시작 권한 확인 (수정됨)
+ // 🚫 편집 시작 권한 확인
activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => {
console.log(`🎯 EditStarting: Row ${info.row}, Col ${info.col}`);
-
- // ✅ 정확한 매핑 찾기 (행/열 정확히 일치)
+
const exactMapping = mappings.find(m => {
const cellPos = parseCellAddress(m.cellAddress);
return cellPos && cellPos.row === info.row && cellPos.col === info.col;
@@ -711,12 +821,11 @@ export function TemplateViewDialog({
if (!exactMapping) {
console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`);
- return; // 매핑이 없으면 허용 (템플릿 영역 밖)
+ return; // 매핑이 없으면 허용
}
- console.log(`📋 Found mapping: ${exactMapping.attId} at ${exactMapping.cellAddress}`);
+ console.log(`📋 Found mapping: ${exactMapping.attId}, isEditable: ${exactMapping.isEditable}`);
- // 기본 편집 권한 확인
if (!exactMapping.isEditable) {
console.log(`🚫 Field ${exactMapping.attId} is not editable`);
toast.warning(`${exactMapping.attId} field is read-only`);
@@ -724,12 +833,9 @@ export function TemplateViewDialog({
return;
}
- // SPREAD_LIST 또는 GRD_LIST 개별 행 SHI 확인
+ // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인
if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) {
const dataRowIndex = exactMapping.dataRowIndex;
-
- console.log(`🔍 Checking SHI for data row ${dataRowIndex}`);
-
if (dataRowIndex >= 0 && dataRowIndex < tableData.length) {
const rowData = tableData[dataRowIndex];
if (rowData?.shi === true) {
@@ -738,41 +844,31 @@ export function TemplateViewDialog({
info.cancel = true;
return;
}
- } else {
- console.warn(`⚠️ Invalid dataRowIndex: ${dataRowIndex} (tableData.length: ${tableData.length})`);
}
}
console.log(`✅ Edit allowed for ${exactMapping.attId}`);
});
- // ✅ 편집 완료 검증 (수정됨)
+ // ✅ 편집 완료 검증
activeSheet.bind(GC.Spread.Sheets.Events.EditEnded, (event: any, info: any) => {
- console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}`);
-
- // ✅ 정확한 매핑 찾기
+ console.log(`🏁 EditEnded: Row ${info.row}, Col ${info.col}, New value: ${activeSheet.getValue(info.row, info.col)}`);
+
const exactMapping = mappings.find(m => {
const cellPos = parseCellAddress(m.cellAddress);
return cellPos && cellPos.row === info.row && cellPos.col === info.col;
});
- if (!exactMapping) {
- console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - skipping validation`);
- return;
- }
+ if (!exactMapping) return;
const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId);
if (columnConfig) {
const cellValue = activeSheet.getValue(info.row, info.col);
- console.log(`🔍 Validating ${exactMapping.attId}: "${cellValue}"`);
-
const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options);
const cell = activeSheet.getCell(info.row, info.col);
if (errorMessage) {
- console.log(`❌ Validation failed: ${errorMessage}`);
-
- // 🚨 에러 스타일 적용 (편집 가능 상태 유지)
+ // 🚨 에러 스타일 적용
const errorStyle = new GC.Spread.Sheets.Style();
errorStyle.backColor = "#fef2f2";
errorStyle.foreColor = "#dc2626";
@@ -780,222 +876,221 @@ export function TemplateViewDialog({
errorStyle.borderRight = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
errorStyle.borderTop = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
errorStyle.borderBottom = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
-
+
activeSheet.setStyle(info.row, info.col, errorStyle);
- cell.locked(!exactMapping.isEditable);
-
- toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}. Please correct the value.`, { duration: 5000 });
+ cell.locked(!exactMapping.isEditable); // 편집 가능 상태 유지
+ toast.warning(`Invalid value in ${exactMapping.attId}: ${errorMessage}`, { duration: 5000 });
} else {
- console.log(`✅ Validation passed`);
-
// ✅ 정상 스타일 복원
const normalStyle = createCellStyle(exactMapping.isEditable);
activeSheet.setStyle(info.row, info.col, normalStyle);
cell.locked(!exactMapping.isEditable);
}
}
+
+ setHasChanges(true);
});
- console.log(`🛡️ Protection and events configured for ${mappings.length} mappings`);
+ console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`);
}, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]);
- // ═══════════════════════════════════════════════════════════════════════════════
- // 🏗️ 메인 SpreadSheets 초기화 함수
- // ═══════════════════════════════════════════════════════════════════════════════
-
- const initSpread = React.useCallback((spread: any, template?: TemplateItem) => {
+ // 🚀 최적화된 initSpread
+ const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => {
const workingTemplate = template || selectedTemplate;
- if (!spread || !workingTemplate) return;
+ if (!spread || !workingTemplate) {
+ console.error('❌ Invalid spread or template');
+ return;
+ }
try {
- // 🔄 초기 설정
+ console.log('🚀 Starting optimized spread initialization...');
+ setIsInitializing(true);
+ updateProgress('Initializing...', 0, 100);
+
setCurrentSpread(spread);
setHasChanges(false);
setValidationErrors([]);
- // 성능을 위한 렌더링 일시 중단
+ // 🚀 핵심 최적화: 모든 렌더링과 이벤트 중단
spread.suspendPaint();
+ spread.suspendEvent();
+ spread.suspendCalcService();
+
+ updateProgress('Setting up workspace...', 10, 100);
try {
- const activeSheet = spread.getActiveSheet();
-
- // 시트 보호 해제 (편집을 위해)
- activeSheet.options.isProtected = false;
+ let activeSheet = getSafeActiveSheet(spread, 'initSpread');
+ if (!activeSheet) {
+ throw new Error('Failed to get initial activeSheet');
+ }
+ activeSheet.options.isProtected = false;
let mappings: CellMapping[] = [];
- // 🆕 GRD_LIST 처리
- if (templateType === 'GRD_LIST' && workingTemplate.GRD_LST_SETUP) {
- console.log('🏗️ Processing GRD_LIST template');
+ if (templateType === 'GRD_LIST') {
+ updateProgress('Creating dynamic table...', 20, 100);
- // 기본 워크북 설정
spread.clearSheets();
spread.addSheet(0);
const sheet = spread.getSheet(0);
sheet.name('Data');
spread.setActiveSheet('Data');
- // 동적 테이블 생성
- mappings = createGrdListTable(sheet, workingTemplate);
+ updateProgress('Processing table data...', 50, 100);
+ mappings = createGrdListTableOptimized(sheet, workingTemplate);
} else {
- // 기존 SPREAD_LIST 및 SPREAD_ITEM 처리
- let contentJson = null;
- let dataSheets = null;
-
- // SPR_LST_SETUP.CONTENT 우선 사용
- if (workingTemplate.SPR_LST_SETUP?.CONTENT) {
- contentJson = workingTemplate.SPR_LST_SETUP.CONTENT;
- dataSheets = workingTemplate.SPR_LST_SETUP.DATA_SHEETS;
- console.log('✅ Using SPR_LST_SETUP.CONTENT for template:', workingTemplate.NAME);
- }
- // SPR_ITM_LST_SETUP.CONTENT 대안 사용
- else if (workingTemplate.SPR_ITM_LST_SETUP?.CONTENT) {
- contentJson = workingTemplate.SPR_ITM_LST_SETUP.CONTENT;
- dataSheets = workingTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS;
- console.log('✅ Using SPR_ITM_LST_SETUP.CONTENT for template:', workingTemplate.NAME);
- }
+ updateProgress('Loading template structure...', 20, 100);
+
+ let contentJson = workingTemplate.SPR_LST_SETUP?.CONTENT || workingTemplate.SPR_ITM_LST_SETUP?.CONTENT;
+ let dataSheets = workingTemplate.SPR_LST_SETUP?.DATA_SHEETS || workingTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS;
- if (!contentJson) {
- console.warn('❌ No CONTENT found in template:', workingTemplate.NAME);
- return;
+ if (!contentJson || !dataSheets) {
+ throw new Error(`No template content found for ${workingTemplate.NAME}`);
}
- // 🏗️ SpreadSheets 초기화
const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
- // 템플릿 구조 로드
+ updateProgress('Loading template layout...', 40, 100);
spread.fromJSON(jsonData);
+
+ activeSheet = getSafeActiveSheet(spread, 'after-fromJSON');
+ if (!activeSheet) {
+ throw new Error('ActiveSheet became null after loading template');
+ }
+
+ activeSheet.options.isProtected = false;
- // 📊 셀 매핑 및 데이터 처리
- if (dataSheets && dataSheets.length > 0) {
+ if (templateType === 'SPREAD_LIST' && tableData.length > 0) {
+ updateProgress('Processing data rows...', 60, 100);
- // 🔄 각 데이터 시트의 매핑 정보 처리
dataSheets.forEach(dataSheet => {
- if (dataSheet.MAP_CELL_ATT) {
- dataSheet.MAP_CELL_ATT.forEach(mapping => {
+ if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) {
+ dataSheet.MAP_CELL_ATT.forEach((mapping: any) => {
const { ATT_ID, IN } = mapping;
-
- if (IN && IN.trim() !== "") {
- const cellPos = parseCellAddress(IN);
- if (cellPos) {
- const columnConfig = columnsJSON.find(col => col.key === ATT_ID);
-
- // 🎯 템플릿 타입별 데이터 처리
- if (templateType === 'SPREAD_ITEM' && selectedRow) {
- // 📝 단일 행 처리 (SPREAD_ITEM)
- const isEditable = isFieldEditable(ATT_ID);
-
- // 매핑 정보 저장
- mappings.push({
- attId: ATT_ID,
- cellAddress: IN,
- isEditable: isEditable,
- dataRowIndex: 0
- });
-
- const cell = activeSheet.getCell(cellPos.row, cellPos.col);
- const value = selectedRow[ATT_ID];
-
- // 값 설정
- cell.value(value ?? null);
-
- // 🎨 스타일 및 편집 권한 설정
- cell.locked(!isEditable);
- const style = createCellStyle(isEditable);
- activeSheet.setStyle(cellPos.row, cellPos.col, style);
-
- // 📋 LIST 타입 드롭다운 설정
- if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) {
- setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1);
- }
-
- } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) {
- // 📊 복수 행 처리 (SPREAD_LIST) - 성능 최적화됨
- console.log(`🔄 Processing SPREAD_LIST for ${ATT_ID} with ${tableData.length} rows`);
-
- // 🚀 행 확장 (필요시)
- ensureRowCapacity(activeSheet, cellPos.row + tableData.length);
-
- // 📋 각 행마다 개별 매핑 생성
- tableData.forEach((rowData, index) => {
- const targetRow = cellPos.row + index;
- const targetCellAddress = `${String.fromCharCode(65 + cellPos.col)}${targetRow + 1}`;
- const cellEditable = isFieldEditable(ATT_ID, rowData);
-
- // 개별 매핑 추가
- mappings.push({
- attId: ATT_ID,
- cellAddress: targetCellAddress, // 각 행마다 다른 주소
- isEditable: cellEditable,
- dataRowIndex: index // 원본 데이터 인덱스
- });
-
- console.log(`📝 Mapping ${ATT_ID} Row ${index}: ${targetCellAddress} (${cellEditable ? 'Editable' : 'ReadOnly'})`);
- });
-
- // 📋 LIST 타입 드롭다운 설정 (조건부)
- if (columnConfig?.type === "LIST" && columnConfig.options) {
- // 편집 가능한 행이 하나라도 있으면 드롭다운 설정
- const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData));
- if (hasEditableRows) {
- setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length);
- }
- }
-
- // 🎨 개별 셀 데이터 및 스타일 설정
- tableData.forEach((rowData, index) => {
- const targetRow = cellPos.row + index;
- const cell = activeSheet.getCell(targetRow, cellPos.col);
- const value = rowData[ATT_ID];
-
- // 값 설정
- cell.value(value ?? null);
- // console.log(`📝 Row ${targetRow}: ${ATT_ID} = "${value}"`);
-
- // 편집 권한 및 스타일 설정
- const cellEditable = isFieldEditable(ATT_ID, rowData);
- cell.locked(!cellEditable);
- const style = createCellStyle(cellEditable);
- activeSheet.setStyle(targetRow, cellPos.col, style);
- });
- }
-
- // console.log(`📌 Mapped ${ATT_ID} → ${IN} (${templateType})`);
+ if (!ATT_ID || !IN || IN.trim() === "") return;
+
+ const cellPos = parseCellAddress(IN);
+ if (!cellPos) return;
+
+ const requiredRows = cellPos.row + tableData.length;
+ if (!ensureRowCapacity(activeSheet, requiredRows)) return;
+
+ // 🚀 배치 데이터 준비
+ const valuesToSet: Array<{row: number, col: number, value: any}> = [];
+ const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = [];
+
+ tableData.forEach((rowData, index) => {
+ const targetRow = cellPos.row + index;
+ const cellEditable = isFieldEditable(ATT_ID, rowData);
+ const value = rowData[ATT_ID];
+
+ mappings.push({
+ attId: ATT_ID,
+ cellAddress: getCellAddress(targetRow, cellPos.col),
+ isEditable: cellEditable,
+ dataRowIndex: index
+ });
+
+ valuesToSet.push({
+ row: targetRow,
+ col: cellPos.col,
+ value: value ?? null
+ });
+
+ stylesToSet.push({
+ row: targetRow,
+ col: cellPos.col,
+ isEditable: cellEditable
+ });
+ });
+
+ // 🚀 배치 처리
+ setBatchValues(activeSheet, valuesToSet);
+ setBatchStyles(activeSheet, stylesToSet);
+
+ // 드롭다운 설정
+ const columnConfig = columnsJSON.find(col => col.key === ATT_ID);
+ if (columnConfig?.type === "LIST" && columnConfig.options) {
+ const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData));
+ if (hasEditableRows) {
+ setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length);
}
}
});
}
});
+
+ } else if (templateType === 'SPREAD_ITEM' && selectedRow) {
+ updateProgress('Setting up form fields...', 60, 100);
+
+ dataSheets.forEach(dataSheet => {
+ dataSheet.MAP_CELL_ATT?.forEach((mapping: any) => {
+ const { ATT_ID, IN } = mapping;
+ const cellPos = parseCellAddress(IN);
+ if (cellPos) {
+ const isEditable = isFieldEditable(ATT_ID);
+ const value = selectedRow[ATT_ID];
+
+ mappings.push({
+ attId: ATT_ID,
+ cellAddress: IN,
+ isEditable: isEditable,
+ dataRowIndex: 0
+ });
+
+ const cell = activeSheet.getCell(cellPos.row, cellPos.col);
+ cell.value(value ?? null);
+
+ const style = createCellStyle(isEditable);
+ activeSheet.setStyle(cellPos.row, cellPos.col, style);
+
+ const columnConfig = columnsJSON.find(col => col.key === ATT_ID);
+ if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) {
+ setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1);
+ }
+ }
+ });
+ });
}
}
- // 💾 매핑 정보 저장 및 이벤트 설정
+ updateProgress('Configuring interactions...', 90, 100);
setCellMappings(mappings);
- setupSheetProtectionAndEvents(activeSheet, mappings);
+
+ const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents');
+ if (finalActiveSheet) {
+ setupSheetProtectionAndEvents(finalActiveSheet, mappings);
+ }
+
+ updateProgress('Finalizing...', 100, 100);
+ console.log(`✅ Optimized initialization completed with ${mappings.length} mappings`);
} finally {
- // 렌더링 재개
+ // 🚀 올바른 순서로 재개
+ spread.resumeCalcService();
+ spread.resumeEvent();
spread.resumePaint();
}
} catch (error) {
- console.error('❌ Error initializing spread:', error);
- toast.error('Failed to load template');
- if (spread?.resumePaint) {
- spread.resumePaint();
- }
+ console.error('❌ Error in optimized spread initialization:', error);
+ if (spread?.resumeCalcService) spread.resumeCalcService();
+ if (spread?.resumeEvent) spread.resumeEvent();
+ if (spread?.resumePaint) spread.resumePaint();
+ toast.error(`Template loading failed: ${error.message}`);
+ } finally {
+ setIsInitializing(false);
+ setLoadingProgress(null);
}
- }, [selectedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON, createCellStyle, setupOptimizedListValidation, ensureRowCapacity, setupSheetProtectionAndEvents, createGrdListTable]);
+ }, [selectedTemplate, templateType, selectedRow, tableData, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings]);
- // 변경사항 저장 함수
const handleSaveChanges = React.useCallback(async () => {
if (!currentSpread || !hasChanges) {
toast.info("No changes to save");
return;
}
- // 저장 전 데이터 검증
const errors = validateAllData();
if (errors.length > 0) {
toast.error(`Cannot save: ${errors.length} validation errors found. Please fix them first.`);
@@ -1004,11 +1099,9 @@ export function TemplateViewDialog({
try {
setIsPending(true);
-
const activeSheet = currentSpread.getActiveSheet();
if (templateType === 'SPREAD_ITEM' && selectedRow) {
- // 단일 행 저장
const dataToSave = { ...selectedRow };
cellMappings.forEach(mapping => {
@@ -1038,7 +1131,6 @@ export function TemplateViewDialog({
onUpdateSuccess?.(dataToSave);
} else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) {
- // 복수 행 저장 (SPREAD_LIST와 GRD_LIST 동일 처리)
const updatedRows: GenericData[] = [];
let saveCount = 0;
@@ -1047,7 +1139,6 @@ export function TemplateViewDialog({
const dataToSave = { ...originalRow };
let hasRowChanges = false;
- // 각 매핑에 대해 해당 행의 값 확인
cellMappings.forEach(mapping => {
if (mapping.dataRowIndex === i && mapping.isEditable) {
const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
@@ -1058,8 +1149,6 @@ export function TemplateViewDialog({
const cellPos = parseCellAddress(mapping.cellAddress);
if (cellPos) {
const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
-
- // 값이 변경되었는지 확인
if (cellValue !== originalRow[mapping.attId]) {
dataToSave[mapping.attId] = cellValue;
hasRowChanges = true;
@@ -1069,10 +1158,8 @@ export function TemplateViewDialog({
}
});
- // 변경사항이 있는 행만 저장
if (hasRowChanges) {
- dataToSave.TAG_NO = originalRow.TAG_NO; // TAG_NO는 절대 변경되지 않도록
-
+ dataToSave.TAG_NO = originalRow.TAG_NO;
const { success } = await updateFormDataInDB(
formCode,
contractItemId,
@@ -1084,7 +1171,7 @@ export function TemplateViewDialog({
saveCount++;
}
} else {
- updatedRows.push(originalRow); // 변경사항이 없으면 원본 유지
+ updatedRows.push(originalRow);
}
}
@@ -1109,21 +1196,18 @@ export function TemplateViewDialog({
if (!isOpen) return null;
- // 데이터 유효성 검사
const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0;
const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length;
-
+
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
- className="w-[80%] max-w-none h-[80vh] flex flex-col"
- style={{ maxWidth: "80vw" }}
+ className="w-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50"
>
<DialogHeader className="flex-shrink-0">
<DialogTitle>SEDP Template - {formCode}</DialogTitle>
<DialogDescription>
<div className="space-y-3">
- {/* 템플릿 선택 */}
{availableTemplates.length > 1 && (
<div className="flex items-center gap-4">
<span className="text-sm font-medium">Template:</span>
@@ -1134,11 +1218,7 @@ export function TemplateViewDialog({
<SelectContent>
{availableTemplates.map(template => (
<SelectItem key={template.TMPL_ID} value={template.TMPL_ID}>
- {template.NAME} ({
- template.GRD_LST_SETUP && columnsJSON.length > 0
- ? 'GRD_LIST'
- : template.TMPL_TYPE
- })
+ {template.NAME} ({template.TMPL_TYPE})
</SelectItem>
))}
</SelectContent>
@@ -1146,14 +1226,13 @@ export function TemplateViewDialog({
</div>
)}
- {/* 템플릿 정보 */}
{selectedTemplate && (
<div className="flex items-center gap-4 text-sm">
<span className="font-medium text-blue-600">
Template Type: {
- templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' :
- templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' :
- 'Grid List View (GRD_LIST)'
+ templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' :
+ templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' :
+ 'Grid List View (GRD_LIST)'
}
</span>
{templateType === 'SPREAD_ITEM' && selectedRow && (
@@ -1176,7 +1255,6 @@ export function TemplateViewDialog({
</div>
)}
- {/* 범례 */}
<div className="flex items-center gap-4 text-xs">
<span className="text-muted-foreground">
<span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span>
@@ -1200,8 +1278,15 @@ export function TemplateViewDialog({
</DialogDescription>
</DialogHeader>
- {/* SpreadSheets 컴포넌트 영역 */}
- <div className="flex-1 overflow-hidden">
+ <div className="flex-1 overflow-hidden relative">
+ {/* 🆕 로딩 프로그레스 오버레이 */}
+ <LoadingProgress
+ phase={loadingProgress?.phase || ''}
+ progress={loadingProgress?.progress || 0}
+ total={loadingProgress?.total || 100}
+ isVisible={isInitializing && !!loadingProgress}
+ />
+
{selectedTemplate && isClient && isDataValid ? (
<SpreadSheets
key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`}
diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx
index 91cb7a07..6de68a1a 100644
--- a/components/form-data/update-form-sheet.tsx
+++ b/components/form-data/update-form-sheet.tsx
@@ -104,8 +104,10 @@ export function UpdateTagSheet({
const isFieldEditable = React.useCallback((column: DataTableColumnJSON) => {
if (column.shi === true) return false; // SHI‑only
- if (column.key === "TAG_NO" || column.key === "TAG_DESC") return true;
- return editableFields.includes(column.key);
+ if (column.key === "TAG_NO" || column.key === "TAG_DESC") return false;
+ if (column.key === "status") return false;
+ // return editableFields.includes(column.key);
+ return true
}, [editableFields]);
const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]);
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx
index 1bc2fc38..0c3b2276 100644
--- a/components/pq-input/pq-input-tabs.tsx
+++ b/components/pq-input/pq-input-tabs.tsx
@@ -505,7 +505,7 @@ export function PQInputTabs({
description: "Your PQ information has been submitted successfully",
});
// 제출 후 PQ 목록 페이지로 리디렉션
- window.location.href = "/partners/pq";
+ window.location.href = "/partners/pq_new";
} else {
toast({
title: "Submit Error",
diff --git a/components/pq/client-pq-input-wrapper.tsx b/components/pq/client-pq-input-wrapper.tsx
deleted file mode 100644
index 42d2420d..00000000
--- a/components/pq/client-pq-input-wrapper.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Shell } from "@/components/shell"
-import { Skeleton } from "@/components/ui/skeleton"
-import { PQInputTabs } from "@/components/pq/pq-input-tabs"
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { PQGroupData, ProjectPQ } from "@/lib/pq/service"
-import { useRouter, useSearchParams } from "next/navigation"
-
-interface ClientPQWrapperProps {
- pqData: PQGroupData[] // 변경: allPQData → pqData (현재 선택된 PQ 데이터)
- projectPQs: ProjectPQ[]
- vendorId: number
- rawSearchParams: {
- projectId?: string
- }
-}
-
-export function ClientPQWrapper({
- pqData,
- projectPQs,
- vendorId,
- rawSearchParams
-}: ClientPQWrapperProps) {
- const searchParams = useSearchParams()
- const projectIdParam = searchParams?.get('projectId')
-
- // 클라이언트 측에서 projectId 파싱
- const projectId = projectIdParam ? parseInt(projectIdParam, 10) : undefined
-
- // 현재 프로젝트 정보 찾기
- const currentProject = projectId
- ? projectPQs.find(p => p.projectId === projectId)
- : null
-
- return (
- <Shell className="gap-2">
- {/* 헤더 - 프로젝트 정보 포함 */}
- <div className="space-y-2">
- <h2 className="text-2xl font-bold tracking-tight">
- Pre-Qualification Check Sheet
- {currentProject && (
- <span className="ml-2 text-muted-foreground">
- - {currentProject.projectCode}
- </span>
- )}
- </h2>
- <p className="text-muted-foreground">
- PQ에 적절한 응답을 제출하시기 바랍니다.
- </p>
- </div>
-
- {/* 일반/프로젝트 PQ 선택 탭 */}
- {projectPQs.length > 0 && (
- <div className="border-b">
- <Tabs defaultValue={projectId ? `project-${projectId}` : "general"}>
- <TabsList>
- <TabsTrigger value="general" asChild>
- <a href="/partners/pq">일반 PQ</a>
- </TabsTrigger>
-
- {projectPQs.map(project => (
- <TabsTrigger
- key={project.projectId}
- value={`project-${project.projectId}`}
- asChild
- >
- <a href={`/partners/pq?projectId=${project.projectId}`}>
- {project.projectCode}
- </a>
- </TabsTrigger>
- ))}
- </TabsList>
- </Tabs>
- </div>
- )}
-
- {/* PQ 입력 탭 */}
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- <PQInputTabs
- data={pqData}
- vendorId={vendorId}
- projectId={projectId}
- projectData={currentProject}
- />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/components/pq/pq-input-tabs.tsx b/components/pq/pq-input-tabs.tsx
deleted file mode 100644
index d72eff92..00000000
--- a/components/pq/pq-input-tabs.tsx
+++ /dev/null
@@ -1,884 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import {
- Card,
- CardHeader,
- CardTitle,
- CardDescription,
- CardContent,
-} from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { X, Save, CheckCircle2, AlertTriangle, ChevronsUpDown } from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { useToast } from "@/hooks/use-toast"
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible"
-
-// Form components
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-
-// Custom Dropzone, FileList components
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
-} from "@/components/ui/file-list"
-
-// Dialog components
-import {
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@/components/ui/dialog"
-
-// Additional UI
-import { Separator } from "@/components/ui/separator"
-import { Badge } from "@/components/ui/badge"
-
-// Server actions
-import {
- uploadFileAction,
- savePQAnswersAction,
- submitPQAction,
- ProjectPQ,
-} from "@/lib/pq/service"
-import { PQGroupData } from "@/lib/pq/service"
-import { useRouter } from "next/navigation"
-
-// ----------------------------------------------------------------------
-// 1) Define client-side file shapes
-// ----------------------------------------------------------------------
-interface UploadedFileState {
- fileName: string
- url: string
- size?: number
-}
-
-interface LocalFileState {
- fileObj: File
- uploaded: boolean
-}
-
-// ----------------------------------------------------------------------
-// 2) Zod schema for the entire form
-// ----------------------------------------------------------------------
-const pqFormSchema = z.object({
- answers: z.array(
- z.object({
- criteriaId: z.number(),
- // Must have at least 1 char
- answer: z.string().min(1, "Answer is required"),
-
- // Existing, uploaded files
- uploadedFiles: z
- .array(
- z.object({
- fileName: z.string(),
- url: z.string(),
- size: z.number().optional(),
- })
- )
- .min(1, "At least one file attachment is required"),
-
- // Local (not-yet-uploaded) files
- newUploads: z.array(
- z.object({
- fileObj: z.any(),
- uploaded: z.boolean().default(false),
- })
- ),
-
- // track saved state
- saved: z.boolean().default(false),
- })
- ),
-})
-
-type PQFormValues = z.infer<typeof pqFormSchema>
-
-// ----------------------------------------------------------------------
-// 3) Main Component: PQInputTabs
-// ----------------------------------------------------------------------
-export function PQInputTabs({
- data,
- vendorId,
- projectId,
- projectData,
-}: {
- data: PQGroupData[]
- vendorId: number
- projectId?: number
- projectData?: ProjectPQ | null
-}) {
- const [isSaving, setIsSaving] = React.useState(false)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- const [allSaved, setAllSaved] = React.useState(false)
- const [showConfirmDialog, setShowConfirmDialog] = React.useState(false)
-
- const { toast } = useToast()
- const router = useRouter()
- // ----------------------------------------------------------------------
- // A) Create initial form values
- // Mark items as "saved" if they have existing answer or attachments
- // ----------------------------------------------------------------------
- function createInitialFormValues(): PQFormValues {
- const answers: PQFormValues["answers"] = []
-
- data.forEach((group) => {
- group.items.forEach((item) => {
- // Check if the server item is already "complete"
- const hasExistingAnswer = item.answer && item.answer.trim().length > 0
- const hasExistingAttachments = item.attachments && item.attachments.length > 0
-
- // If either is present, we consider it "saved" initially
- const isAlreadySaved = hasExistingAnswer || hasExistingAttachments
-
- answers.push({
- criteriaId: item.criteriaId,
- answer: item.answer || "",
- uploadedFiles: item.attachments.map((attach) => ({
- fileName: attach.fileName,
- url: attach.filePath,
- size: attach.fileSize,
- })),
- newUploads: [],
- saved: isAlreadySaved,
- })
- })
- })
-
- return { answers }
- }
-
- // ----------------------------------------------------------------------
- // B) Set up react-hook-form
- // ----------------------------------------------------------------------
- const form = useForm<PQFormValues>({
- resolver: zodResolver(pqFormSchema),
- defaultValues: createInitialFormValues(),
- mode: "onChange",
- })
-
- // ----------------------------------------------------------------------
- // C) Track if all items are saved => controls Submit PQ button
- // ----------------------------------------------------------------------
- React.useEffect(() => {
- const values = form.getValues()
- // We consider items "saved" if `saved===true` AND they have an answer or attachments
- const allItemsSaved = values.answers.every(
- (answer) => answer.saved && (answer.answer || answer.uploadedFiles.length > 0)
- )
- setAllSaved(allItemsSaved)
- }, [form.watch()])
-
- // Helper to find the array index by criteriaId
- const getAnswerIndex = (criteriaId: number): number => {
- return form.getValues().answers.findIndex((a) => a.criteriaId === criteriaId)
- }
-
- // ----------------------------------------------------------------------
- // D) Handling File Drops, Removal
- // ----------------------------------------------------------------------
- const handleDropAccepted = (criteriaId: number, files: File[]) => {
- const answerIndex = getAnswerIndex(criteriaId)
- if (answerIndex === -1) return
-
- // Convert each dropped file into a LocalFileState
- const newLocalFiles: LocalFileState[] = files.map((f) => ({
- fileObj: f,
- uploaded: false,
- }))
-
- const current = form.getValues(`answers.${answerIndex}.newUploads`)
- form.setValue(`answers.${answerIndex}.newUploads`, [...current, ...newLocalFiles], {
- shouldDirty: true,
- })
-
- // Mark unsaved
- form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true })
- }
-
- const handleDropRejected = () => {
- toast({
- title: "File upload rejected",
- description: "Please check file size and type.",
- variant: "destructive",
- })
- }
-
- const removeNewUpload = (answerIndex: number, fileIndex: number) => {
- const current = [...form.getValues(`answers.${answerIndex}.newUploads`)]
- current.splice(fileIndex, 1)
- form.setValue(`answers.${answerIndex}.newUploads`, current, { shouldDirty: true })
-
- form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true })
- }
-
- const removeUploadedFile = (answerIndex: number, fileIndex: number) => {
- const current = [...form.getValues(`answers.${answerIndex}.uploadedFiles`)]
- current.splice(fileIndex, 1)
- form.setValue(`answers.${answerIndex}.uploadedFiles`, current, { shouldDirty: true })
-
- form.setValue(`answers.${answerIndex}.saved`, false, { shouldDirty: true })
- }
-
- // ----------------------------------------------------------------------
- // E) Saving a Single Item
- // ----------------------------------------------------------------------
- const handleSaveItem = async (answerIndex: number) => {
- try {
- const answerData = form.getValues(`answers.${answerIndex}`)
-
- // Validation
- if (!answerData.answer) {
- toast({
- title: "Validation Error",
- description: "Answer is required",
- variant: "destructive",
- })
- return
- }
-
- // Upload new files (if any)
- if (answerData.newUploads.length > 0) {
- setIsSaving(true)
-
- for (const localFile of answerData.newUploads) {
- try {
- const uploadResult = await uploadFileAction(localFile.fileObj)
- const currentUploaded = form.getValues(`answers.${answerIndex}.uploadedFiles`)
- currentUploaded.push({
- fileName: uploadResult.fileName,
- url: uploadResult.url,
- size: uploadResult.size,
- })
- form.setValue(`answers.${answerIndex}.uploadedFiles`, currentUploaded, {
- shouldDirty: true,
- })
- } catch (error) {
- console.error("File upload error:", error)
- toast({
- title: "Upload Error",
- description: "Failed to upload file",
- variant: "destructive",
- })
- }
- }
-
- // Clear newUploads
- form.setValue(`answers.${answerIndex}.newUploads`, [], { shouldDirty: true })
- }
-
- // Save to DB
- const updatedAnswer = form.getValues(`answers.${answerIndex}`)
- const saveResult = await savePQAnswersAction({
- vendorId,
- projectId, // 프로젝트 ID 전달
- answers: [
- {
- criteriaId: updatedAnswer.criteriaId,
- answer: updatedAnswer.answer,
- attachments: updatedAnswer.uploadedFiles.map((f) => ({
- fileName: f.fileName,
- url: f.url,
- size: f.size,
- })),
- },
- ],
- })
-
- if (saveResult.ok) {
- // Mark as saved
- form.setValue(`answers.${answerIndex}.saved`, true, { shouldDirty: false })
- toast({
- title: "Saved",
- description: "Item saved successfully",
- })
- }
- } catch (error) {
- console.error("Save error:", error)
- toast({
- title: "Save Error",
- description: "Failed to save item",
- variant: "destructive",
- })
- } finally {
- setIsSaving(false)
- }
- }
-
- // For convenience
- const answers = form.getValues().answers
- const dirtyFields = form.formState.dirtyFields.answers
-
- // Check if any item is dirty or has new uploads
- const isAnyItemDirty = answers.some((answer, i) => {
- const itemDirty = !!dirtyFields?.[i]
- const hasNewUploads = answer.newUploads.length > 0
- return itemDirty || hasNewUploads
- })
-
- // ----------------------------------------------------------------------
- // F) Save All Items
- // ----------------------------------------------------------------------
- const handleSaveAll = async () => {
- try {
- setIsSaving(true)
- const answers = form.getValues().answers
-
- // Only save items that are dirty or have new uploads
- for (let i = 0; i < answers.length; i++) {
- const itemDirty = !!dirtyFields?.[i]
- const hasNewUploads = answers[i].newUploads.length > 0
- if (!itemDirty && !hasNewUploads) continue
-
- await handleSaveItem(i)
- }
-
- toast({
- title: "All Saved",
- description: "All items saved successfully",
- })
- } catch (error) {
- console.error("Save all error:", error)
- toast({
- title: "Save Error",
- description: "Failed to save all items",
- variant: "destructive",
- })
- } finally {
- setIsSaving(false)
- }
- }
-
- // ----------------------------------------------------------------------
- // G) Submission with Confirmation Dialog
- // ----------------------------------------------------------------------
- const handleSubmitPQ = () => {
- if (!allSaved) {
- toast({
- title: "Cannot Submit",
- description: "Please save all items before submitting",
- variant: "destructive",
- })
- return
- }
- setShowConfirmDialog(true)
- }
-
- const handleConfirmSubmission = async () => {
- try {
- setIsSubmitting(true)
- setShowConfirmDialog(false)
-
- const result = await submitPQAction({
- vendorId,
- projectId, // 프로젝트 ID 전달
- })
-
- if (result.ok) {
- toast({
- title: "PQ Submitted",
- description: "Your PQ information has been submitted successfully",
- })
- // 제출 후 페이지 새로고침 또는 리디렉션 처리
- router.refresh()
- // window.location.reload()
- } else {
- toast({
- title: "Submit Error",
- description: result.error || "Failed to submit PQ",
- variant: "destructive",
- })
- }
- } catch (error) {
- console.error("Submit error:", error)
- toast({
- title: "Submit Error",
- description: "Failed to submit PQ information",
- variant: "destructive",
- })
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 프로젝트 정보 표시 섹션
- const renderProjectInfo = () => {
- if (!projectData) return null;
-
- return (
- <div className="mb-6 bg-muted p-4 rounded-md">
- <div className="flex items-center justify-between mb-2">
- <h3 className="text-lg font-semibold">프로젝트 정보</h3>
- <Badge variant={getStatusVariant(projectData.status)}>
- {getStatusLabel(projectData.status)}
- </Badge>
- </div>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div>
- <p className="text-sm font-medium text-muted-foreground">프로젝트 코드</p>
- <p>{projectData.projectCode}</p>
- </div>
- <div>
- <p className="text-sm font-medium text-muted-foreground">프로젝트명</p>
- <p>{projectData.projectName}</p>
- </div>
- {projectData.submittedAt && (
- <div className="col-span-1 md:col-span-2">
- <p className="text-sm font-medium text-muted-foreground">제출일</p>
- <p>{formatDate(projectData.submittedAt)}</p>
- </div>
- )}
- </div>
- </div>
- );
- };
-
- // 상태 표시용 함수
- const getStatusLabel = (status: string) => {
- switch (status) {
- case "REQUESTED": return "요청됨";
- case "IN_PROGRESS": return "진행중";
- case "SUBMITTED": return "제출됨";
- case "APPROVED": return "승인됨";
- case "REJECTED": return "반려됨";
- default: return status;
- }
- };
-
- const getStatusVariant = (status: string) => {
- switch (status) {
- case "REQUESTED": return "secondary";
- case "IN_PROGRESS": return "default";
- case "SUBMITTED": return "outline";
- case "APPROVED": return "outline";
- case "REJECTED": return "destructive";
- default: return "secondary";
- }
- };
-
- // 날짜 형식화 함수
- const formatDate = (date: Date) => {
- if (!date) return "-";
- return new Date(date).toLocaleDateString("ko-KR", {
- year: "numeric",
- month: "long",
- day: "numeric",
- });
- };
-
- // ----------------------------------------------------------------------
- // H) Render
- // ----------------------------------------------------------------------
- return (
- <Form {...form}>
- <form>
- {/* 프로젝트 정보 섹션 */}
- {renderProjectInfo()}
-
- <Tabs defaultValue={data[0]?.groupName || ""} className="w-full">
- {/* Top Controls */}
- <div className="flex justify-between items-center mb-4">
- <TabsList className="grid grid-cols-4">
- {data.map((group) => (
- <TabsTrigger
- key={group.groupName}
- value={group.groupName}
- className="truncate"
- >
- <div className="flex items-center gap-2">
- {/* Mobile: truncated version */}
- <span className="block sm:hidden">
- {group.groupName.length > 5
- ? group.groupName.slice(0, 5) + "..."
- : group.groupName}
- </span>
- {/* Desktop: full text */}
- <span className="hidden sm:block">{group.groupName}</span>
- <span className="inline-flex items-center justify-center h-5 min-w-5 px-1 rounded-full bg-muted text-xs font-medium">
- {group.items.length}
- </span>
- </div>
- </TabsTrigger>
- ))}
- </TabsList>
-
- <div className="flex gap-2">
- {/* Save All button */}
- <Button
- type="button"
- variant="outline"
- disabled={isSaving || !isAnyItemDirty}
- onClick={handleSaveAll}
- >
- {isSaving ? "Saving..." : "Save All"}
- <Save className="ml-2 h-4 w-4" />
- </Button>
-
- {/* Submit PQ button */}
- <Button
- type="button"
- disabled={!allSaved || isSubmitting}
- onClick={handleSubmitPQ}
- >
- {isSubmitting ? "Submitting..." : "Submit PQ"}
- <CheckCircle2 className="ml-2 h-4 w-4" />
- </Button>
- </div>
- </div>
-
- {/* Render each group */}
- {data.map((group) => (
- <TabsContent key={group.groupName} value={group.groupName}>
- {/* 2-column grid */}
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4">
- {group.items.map((item) => {
- const { criteriaId, code, checkPoint, description, contractInfo, additionalRequirement } = item
- const answerIndex = getAnswerIndex(criteriaId)
- if (answerIndex === -1) return null
-
- const isSaved = form.watch(`answers.${answerIndex}.saved`)
- const hasAnswer = form.watch(`answers.${answerIndex}.answer`)
- const newUploads = form.watch(`answers.${answerIndex}.newUploads`)
- const dirtyFieldsItem = form.formState.dirtyFields.answers?.[answerIndex]
-
- const isItemDirty = !!dirtyFieldsItem
- const hasNewUploads = newUploads.length > 0
- const canSave = isItemDirty || hasNewUploads
-
- // For "Not Saved" vs. "Saved" status label
- const hasUploads =
- form.watch(`answers.${answerIndex}.uploadedFiles`).length > 0 ||
- newUploads.length > 0
- const isValid = !!hasAnswer || hasUploads
-
- return (
- <Collapsible key={criteriaId} defaultOpen={!isSaved} className="w-full">
- <Card className={isSaved ? "border-green-200" : ""}>
- <CardHeader className="pb-1">
- <div className="flex justify-between">
- <div className="flex-1">
- <div className="flex items-center gap-2">
- <CollapsibleTrigger asChild>
- <Button variant="ghost" size="sm" className="p-0 h-7 w-7">
- <ChevronsUpDown className="h-4 w-4" />
- <span className="sr-only">Toggle</span>
- </Button>
- </CollapsibleTrigger>
- <CardTitle className="text-md">
- {code} - {checkPoint}
- </CardTitle>
- </div>
- {description && (
- <CardDescription className="mt-1 whitespace-pre-wrap">
- {description}
- </CardDescription>
- )}
- </div>
-
- {/* Save Status & Button */}
- <div className="flex items-center gap-2">
- {!isSaved && canSave && (
- <span className="text-amber-600 text-xs flex items-center">
- <AlertTriangle className="h-4 w-4 mr-1" />
- Not Saved
- </span>
- )}
- {isSaved && (
- <span className="text-green-600 text-xs flex items-center">
- <CheckCircle2 className="h-4 w-4 mr-1" />
- Saved
- </span>
- )}
-
- <Button
- size="sm"
- variant="outline"
- disabled={isSaving || !canSave}
- onClick={() => handleSaveItem(answerIndex)}
- >
- Save
- </Button>
- </div>
- </div>
- </CardHeader>
-
- <CollapsibleContent>
- <CardContent className="pt-3 space-y-3">
- {/* 프로젝트별 추가 필드 (contractInfo, additionalRequirement) */}
- {projectId && contractInfo && (
- <div className="space-y-1">
- <FormLabel className="text-sm font-medium">계약 정보</FormLabel>
- <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap">
- {contractInfo}
- </div>
- </div>
- )}
-
- {projectId && additionalRequirement && (
- <div className="space-y-1">
- <FormLabel className="text-sm font-medium">추가 요구사항</FormLabel>
- <div className="rounded-md bg-muted/30 p-3 text-sm whitespace-pre-wrap">
- {additionalRequirement}
- </div>
- </div>
- )}
-
- {/* Answer Field */}
- <FormField
- control={form.control}
- name={`answers.${answerIndex}.answer`}
- render={({ field }) => (
- <FormItem className="mt-2">
- <FormLabel>Answer</FormLabel>
- <FormControl>
- <Textarea
- {...field}
- className="min-h-24"
- placeholder="Enter your answer here"
- onChange={(e) => {
- field.onChange(e)
- form.setValue(
- `answers.${answerIndex}.saved`,
- false,
- { shouldDirty: true }
- )
- }}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
-
- {/* Attachments / Dropzone */}
- <div className="grid gap-2 mt-3">
- <FormLabel>Attachments</FormLabel>
- <Dropzone
- maxSize={6e8} // 600MB
- onDropAccepted={(files) =>
- handleDropAccepted(criteriaId, files)
- }
- onDropRejected={handleDropRejected}
- >
- {() => (
- <FormItem>
- <DropzoneZone className="flex justify-center h-24">
- <FormControl>
- <DropzoneInput />
- </FormControl>
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>Drop files here</DropzoneTitle>
- <DropzoneDescription>
- Max size: 600MB
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- <FormDescription>
- Or click to browse files
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- </Dropzone>
- </div>
-
- {/* Existing + Pending Files */}
- <div className="mt-4 space-y-4">
- {/* 1) Not-yet-uploaded files */}
- {newUploads.length > 0 && (
- <div className="grid gap-2">
- <h6 className="text-sm font-medium">
- Pending Files ({newUploads.length})
- </h6>
- <FileList>
- {newUploads.map((f, fileIndex) => {
- const fileObj = f.fileObj
- if (!fileObj) return null
-
- return (
- <FileListItem key={fileIndex}>
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{fileObj.name}</FileListName>
- <FileListDescription>
- {prettyBytes(fileObj.size)}
- </FileListDescription>
- </FileListInfo>
- <FileListAction
- onClick={() =>
- removeNewUpload(answerIndex, fileIndex)
- }
- >
- <X className="h-4 w-4" />
- <span className="sr-only">Remove</span>
- </FileListAction>
- </FileListHeader>
- </FileListItem>
- )
- })}
- </FileList>
- </div>
- )}
-
- {/* 2) Already uploaded files */}
- {form
- .watch(`answers.${answerIndex}.uploadedFiles`)
- .map((file, fileIndex) => (
- <FileListItem key={fileIndex}>
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{file.fileName}</FileListName>
- {/* If you want to display the path:
- <FileListDescription>{file.url}</FileListDescription>
- */}
- </FileListInfo>
- {file.size && (
- <span className="text-xs text-muted-foreground">
- {prettyBytes(file.size)}
- </span>
- )}
- <FileListAction
- onClick={() =>
- removeUploadedFile(answerIndex, fileIndex)
- }
- >
- <X className="h-4 w-4" />
- <span className="sr-only">Remove</span>
- </FileListAction>
- </FileListHeader>
- </FileListItem>
- ))}
- </div>
- </CardContent>
- </CollapsibleContent>
- </Card>
- </Collapsible>
- )
- })}
- </div>
- </TabsContent>
- ))}
- </Tabs>
- </form>
-
- {/* Confirmation Dialog */}
- <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Confirm Submission</DialogTitle>
- <DialogDescription>
- {projectId
- ? `${projectData?.projectCode} 프로젝트의 PQ 응답을 제출하시겠습니까?`
- : "일반 PQ 응답을 제출하시겠습니까?"
- } 제출 후에는 수정이 불가능합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4 max-h-[600px] overflow-y-auto ">
- {data.map((group) => (
- <Collapsible key={group.groupName} defaultOpen>
- <CollapsibleTrigger asChild>
- <div className="flex justify-between items-center p-2 mb-1 cursor-pointer ">
- <p className="font-semibold">{group.groupName}</p>
- <ChevronsUpDown className="h-4 w-4 ml-2" />
- </div>
- </CollapsibleTrigger>
-
- <CollapsibleContent>
- {group.items.map((item) => {
- const answerObj = form
- .getValues()
- .answers.find((a) => a.criteriaId === item.criteriaId)
-
- if (!answerObj) return null
-
- return (
- <div key={item.criteriaId} className="mb-2 p-2 ml-2 border rounded-md text-sm">
- {/* code & checkPoint */}
- <p className="font-semibold">
- {item.code} - {item.checkPoint}
- </p>
-
- {/* user's typed answer */}
- <p className="text-sm font-medium mt-2">Answer:</p>
- <p className="whitespace-pre-wrap text-sm">
- {answerObj.answer || "(no answer)"}
- </p>
- {/* attachments */}
- <p>Attachments:</p>
- {answerObj.uploadedFiles.length > 0 ? (
- <ul className="list-disc list-inside ml-4 text-xs">
- {answerObj.uploadedFiles.map((file, idx) => (
- <li key={idx}>{file.fileName}</li>
- ))}
- </ul>
- ) : (
- <p className="text-xs text-muted-foreground">(none)</p>
- )}
- </div>
- )
- })}
- </CollapsibleContent>
- </Collapsible>
- ))}
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setShowConfirmDialog(false)}
- disabled={isSubmitting}
- >
- Cancel
- </Button>
- <Button onClick={handleConfirmSubmission} disabled={isSubmitting}>
- {isSubmitting ? "Submitting..." : "Confirm Submit"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </Form>
- )
-} \ No newline at end of file
diff --git a/components/pq/pq-review-detail.tsx b/components/pq/pq-review-detail.tsx
deleted file mode 100644
index 4f897a2b..00000000
--- a/components/pq/pq-review-detail.tsx
+++ /dev/null
@@ -1,888 +0,0 @@
-"use client"
-
-import React from "react"
-import { Button } from "@/components/ui/button"
-import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
-import { Textarea } from "@/components/ui/textarea"
-import { useToast } from "@/hooks/use-toast"
-import {
- PQGroupData,
- requestPqChangesAction,
- updateVendorStatusAction,
- updateProjectPQStatusAction,
- getItemReviewLogsAction
-} from "@/lib/pq/service"
-import { Vendor } from "@/db/schema/vendors"
-import { Separator } from "@/components/ui/separator"
-import { Badge } from "@/components/ui/badge"
-import { ChevronsUpDown, MessagesSquare, Download, Loader2, X } from "lucide-react"
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { Card } from "@/components/ui/card"
-import { formatDate } from "@/lib/utils"
-import { downloadFileAction } from "@/lib/downloadFile"
-import { useSession } from "next-auth/react" // Importando o hook do next-auth
-
-// 코멘트 상태를 위한 인터페이스 정의
-interface PendingComment {
- answerId: number;
- checkPoint: string;
- code: string;
- comment: string;
- createdAt: Date;
-}
-
-interface ReviewLog {
- id: number
- reviewerComment: string
- reviewerName: string | null
- createdAt: Date
-}
-
-// Updated props interface to support both general and project PQs
-interface VendorPQAdminReviewProps {
- data: PQGroupData[]
- vendor: Vendor
- projectId?: number
- projectName?: string
- projectStatus?: string
- // loadData: () => Promise<PQGroupData[]>
- loadData: (vendorId: number, projectId?: number) => Promise<PQGroupData[]>
-
- pqType: 'general' | 'project'
-}
-
-export default function VendorPQAdminReview({
- data,
- vendor,
- projectId,
- projectName,
- projectStatus,
- loadData,
- pqType
-}: VendorPQAdminReviewProps) {
- const { toast } = useToast()
- const { data: session } = useSession()
- const reviewerName = session?.user?.name || "Unknown Reviewer"
- const reviewerId = session?.user?.id
-
-
- // State for dynamically loaded data
- const [pqData, setPqData] = React.useState<PQGroupData[]>(data)
- const [isDataLoading, setIsDataLoading] = React.useState(false)
-
- // Load data if not provided initially (for tab switching)
- React.useEffect(() => {
- if (data.length === 0) {
- const fetchData = async () => {
- setIsDataLoading(true)
- try {
- const freshData = await loadData(vendor.id, projectId)
-
- setPqData(freshData)
- } catch (error) {
- console.error("Error loading PQ data:", error)
- toast({
- title: "Error",
- description: "Failed to load PQ data",
- variant: "destructive"
- })
- } finally {
- setIsDataLoading(false)
- }
- }
- fetchData()
- } else {
- setPqData(data)
- }
- }, [data, loadData, vendor.id, projectId, toast])
-
- // 다이얼로그 상태들
- const [showRequestDialog, setShowRequestDialog] = React.useState(false)
- const [showApproveDialog, setShowApproveDialog] = React.useState(false)
- const [showRejectDialog, setShowRejectDialog] = React.useState(false)
-
- // 코멘트 상태들
- const [requestComment, setRequestComment] = React.useState("")
- const [approveComment, setApproveComment] = React.useState("")
- const [rejectComment, setRejectComment] = React.useState("")
- const [isLoading, setIsLoading] = React.useState(false)
-
- // 항목별 코멘트 상태 추적 (메모리에만 저장)
- const [pendingComments, setPendingComments] = React.useState<PendingComment[]>([])
-
- // 코멘트 추가 핸들러 - 실제 서버 저장이 아닌 메모리에 저장
- const handleCommentAdded = (newComment: PendingComment) => {
- setPendingComments(prev => [...prev, newComment]);
- toast({
- title: "Comment Added",
- description: `Comment added for ${newComment.code}. Please "Request Changes" to save.`
- });
- }
-
- // 코멘트 삭제 핸들러
- const handleRemoveComment = (index: number) => {
- setPendingComments(prev => prev.filter((_, i) => i !== index));
- }
-
- // 1) 승인 다이얼로그 표시
- const handleApprove = () => {
- // 코멘트가 있는데 승인하려고 하면 경고
- if (pendingComments.length > 0) {
- if (!confirm('You have unsaved comments. Are you sure you want to approve without requesting changes?')) {
- return;
- }
- }
- setShowApproveDialog(true)
- }
-
- // 실제 승인 처리 - 일반 PQ와 프로젝트 PQ 분리
- const handleSubmitApprove = async () => {
- try {
- setIsLoading(true)
- setShowApproveDialog(false)
-
- let res;
-
- if (pqType === 'general') {
- // 일반 PQ 승인
- res = await updateVendorStatusAction(vendor.id, "PQ_APPROVED")
- } else if (projectId) {
- // 프로젝트 PQ 승인
- res = await updateProjectPQStatusAction({
- vendorId: vendor.id,
- projectId,
- status: "APPROVED",
- comment: approveComment.trim() || undefined
- })
- }
-
- if (res?.ok) {
- toast({
- title: "Approved",
- description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been approved.`
- })
- // 코멘트 초기화
- setPendingComments([]);
- } else {
- toast({
- title: "Error",
- description: res?.error || "An error occurred",
- variant: "destructive"
- })
- }
- } catch (error) {
- toast({ title: "Error", description: String(error), variant: "destructive" })
- } finally {
- setIsLoading(false)
- setApproveComment("")
- }
- }
-
- // 2) 거부 다이얼로그 표시
- const handleReject = () => {
- // 코멘트가 있는데 거부하려고 하면 경고
- if (pendingComments.length > 0) {
- if (!confirm('You have unsaved comments. Are you sure you want to reject without requesting changes?')) {
- return;
- }
- }
- setShowRejectDialog(true)
- }
-
- // 실제 거부 처리 - 일반 PQ와 프로젝트 PQ 분리
- const handleSubmitReject = async () => {
- try {
- setIsLoading(true)
- setShowRejectDialog(false)
-
- if (!rejectComment.trim()) {
- toast({
- title: "Error",
- description: "Please provide a reason for rejection",
- variant: "destructive"
- })
- return;
- }
-
- let res;
-
- if (pqType === 'general') {
- // 일반 PQ 거부
- res = await updateVendorStatusAction(vendor.id, "REJECTED")
- } else if (projectId) {
- // 프로젝트 PQ 거부
- res = await updateProjectPQStatusAction({
- vendorId: vendor.id,
- projectId,
- status: "REJECTED",
- comment: rejectComment
- })
- }
-
- if (res?.ok) {
- toast({
- title: "Rejected",
- description: `${pqType === 'general' ? 'General' : 'Project'} PQ has been rejected.`
- })
- // 코멘트 초기화
- setPendingComments([]);
- } else {
- toast({
- title: "Error",
- description: res?.error || "An error occurred",
- variant: "destructive"
- })
- }
- } catch (error) {
- toast({ title: "Error", description: String(error), variant: "destructive" })
- } finally {
- setIsLoading(false)
- setRejectComment("")
- }
- }
-
- // 3) 변경 요청 다이얼로그 표시
- const handleRequestChanges = () => {
- setShowRequestDialog(true)
- }
-
- // 4) 변경 요청 처리 - 이제 프로젝트 ID 포함
- const handleSubmitRequestChanges = async () => {
- try {
- setIsLoading(true);
- setShowRequestDialog(false);
-
- // 항목별 코멘트 준비 - answerId와 함께 checkPoint와 code도 전송
- const itemComments = pendingComments.map(pc => ({
- answerId: pc.answerId,
- checkPoint: pc.checkPoint,
- code: pc.code,
- comment: pc.comment
- }));
-
- // 서버 액션 호출 (프로젝트 ID 추가)
- const res = await requestPqChangesAction({
- vendorId: vendor.id,
- projectId: pqType === 'project' ? projectId : undefined,
- comment: itemComments,
- generalComment: requestComment || undefined,
- reviewerName,
- reviewerId
- });
-
- if (res.ok) {
- toast({
- title: "Changes Requested",
- description: `${pqType === 'general' ? 'Vendor' : 'Project'} was notified of your comments.`,
- });
- // 코멘트 초기화
- setPendingComments([]);
- } else {
- toast({
- title: "Error",
- description: res.error,
- variant: "destructive"
- });
- }
- } catch (error) {
- toast({
- title: "Error",
- description: String(error),
- variant: "destructive"
- });
- } finally {
- setIsLoading(false);
- setRequestComment("");
- }
- };
-
- // 현재 상태에 따른 액션 버튼 비활성화 여부 판단
- const getDisabledState = () => {
- if (pqType === 'general') {
- // 일반 PQ는 vendor 상태에 따라 결정
- return vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED';
- } else if (pqType === 'project' && projectStatus) {
- // 프로젝트 PQ는 project 상태에 따라 결정
- return projectStatus === 'APPROVED' || projectStatus === 'REJECTED';
- }
- return false;
- };
-
- const areActionsDisabled = getDisabledState();
-
- return (
- <div className="space-y-4">
- {/* PQ Type indicators and status */}
- {pqType === 'project' && projectName && (
- <div className="flex flex-col space-y-1 mb-4">
- <div className="flex items-center gap-2">
- <Badge variant="outline">{projectName}</Badge>
- {projectStatus && (
- <Badge className={
- projectStatus === 'APPROVED' ? 'bg-green-100 text-green-800' :
- projectStatus === 'REJECTED' ? 'bg-red-100 text-red-800' :
- 'bg-blue-100 text-blue-800'
- }>
- {projectStatus}
- </Badge>
- )}
- </div>
- {areActionsDisabled && (
- <p className="text-sm text-muted-foreground">
- This PQ has already been {
- pqType !== 'project'
- ? (vendor.status === 'PQ_APPROVED' || vendor.status === 'APPROVED' ? 'approved' : 'rejected')
- : (projectStatus === 'APPROVED' ? 'approved' : 'rejected')
- }. No further actions can be taken.
- </p>
- )}
- </div>
- )}
-
- {/* Loading indicator */}
- {isDataLoading && (
- <div className="flex justify-center items-center h-32">
- <Loader2 className="h-8 w-8 animate-spin text-primary" />
- </div>
- )}
-
- {!isDataLoading && (
- <>
- {/* Top header */}
- <div className="flex items-center justify-between">
- <h2 className="text-2xl font-bold">
- {vendor.vendorCode} - {vendor.vendorName} {pqType === 'project' ? 'Project' : 'General'} PQ Review
- </h2>
- <div className="flex gap-2">
- <Button
- variant="outline"
- disabled={isLoading || areActionsDisabled}
- onClick={handleReject}
- >
- Reject
- </Button>
- <Button
- variant={pendingComments.length > 0 ? "default" : "outline"}
- disabled={isLoading || areActionsDisabled}
- onClick={handleRequestChanges}
- >
- Request Changes
- {pendingComments.length > 0 && (
- <span className="ml-2 bg-white text-primary rounded-full h-5 min-w-5 inline-flex items-center justify-center text-xs px-1">
- {pendingComments.length}
- </span>
- )}
- </Button>
- <Button
- disabled={isLoading || areActionsDisabled}
- onClick={handleApprove}
- >
- Approve
- </Button>
- </div>
- </div>
-
- <p className="text-sm text-muted-foreground">
- Review the submitted PQ items below, then approve, reject, or request more info.
- </p>
-
- {/* 코멘트가 있을 때 알림 표시 */}
- {pendingComments.length > 0 && (
- <div className="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md text-yellow-800">
- <p className="text-sm font-medium flex items-center">
- <span className="mr-2">⚠️</span>
- You have {pendingComments.length} pending comments. Click "Request Changes" to save them.
- </p>
- </div>
- )}
-
- <Separator />
-
- {/* PQ 데이터 표시 */}
- {pqData.length > 0 ? (
- <VendorPQReviewPageIntegrated
- data={pqData}
- onCommentAdded={handleCommentAdded}
- />
- ) : (
- <div className="text-center py-10">
- <p className="text-muted-foreground">No PQ data available for review.</p>
- </div>
- )}
- </>
- )}
-
- {/* 변경 요청 다이얼로그 */}
- <Dialog open={showRequestDialog} onOpenChange={setShowRequestDialog}>
- <DialogContent className="max-w-3xl">
- <DialogHeader>
- <DialogTitle>Request PQ Changes</DialogTitle>
- <DialogDescription>
- Review your comments and add any additional notes. The vendor will receive these changes.
- </DialogDescription>
- </DialogHeader>
-
- {/* 항목별 코멘트 목록 */}
- {pendingComments.length > 0 && (
- <div className="border rounded-md p-2 space-y-2 max-h-[300px] overflow-y-auto">
- <h3 className="font-medium text-sm">Item Comments:</h3>
- {pendingComments.map((comment, index) => (
- <div key={index} className="flex items-start gap-2 p-2 border rounded-md bg-muted/50">
- <div className="flex-1">
- <div className="flex items-center gap-2">
- <span className="text-sm font-medium">{comment.code}</span>
- <span className="text-sm">{comment.checkPoint}</span>
- </div>
- <p className="text-sm mt-1">{comment.comment}</p>
- <p className="text-xs text-muted-foreground mt-1">
- {formatDate(comment.createdAt, "KR")}
- </p>
- </div>
- <Button
- variant="ghost"
- size="sm"
- className="p-0 h-8 w-8"
- onClick={() => handleRemoveComment(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- )}
-
- {/* 추가 코멘트 입력 */}
- <div className="space-y-2 mt-2">
- <label className="text-sm font-medium">
- {pendingComments.length > 0
- ? "Additional comments (optional):"
- : "Enter details about what should be modified:"}
- </label>
- <Textarea
- value={requestComment}
- onChange={(e) => setRequestComment(e.target.value)}
- placeholder={pendingComments.length > 0
- ? "Add any additional notes..."
- : "Please correct item #1, etc..."}
- className="min-h-[100px]"
- />
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setShowRequestDialog(false)}
- disabled={isLoading}
- >
- Cancel
- </Button>
- <Button
- onClick={handleSubmitRequestChanges}
- disabled={isLoading || (pendingComments.length === 0 && !requestComment.trim())}
- >
- Submit Changes
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-
- {/* 승인 확인 다이얼로그 */}
- <Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Confirm Approval</DialogTitle>
- <DialogDescription>
- Are you sure you want to approve this {pqType === 'project' ? 'project' : 'vendor'} PQ? You can add a comment if needed.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-2">
- <Textarea
- value={approveComment}
- onChange={(e) => setApproveComment(e.target.value)}
- placeholder="Optional: Add any comments about this approval"
- className="min-h-[100px]"
- />
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setShowApproveDialog(false)}
- disabled={isLoading}
- >
- Cancel
- </Button>
- <Button
- onClick={handleSubmitApprove}
- disabled={isLoading}
- >
- Confirm Approval
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-
- {/* 거부 확인 다이얼로그 */}
- <Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Confirm Rejection</DialogTitle>
- <DialogDescription>
- Are you sure you want to reject this {pqType === 'project' ? 'project' : 'vendor'} PQ? Please provide a reason.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-2">
- <Textarea
- value={rejectComment}
- onChange={(e) => setRejectComment(e.target.value)}
- placeholder="Required: Provide reason for rejection"
- className="min-h-[150px]"
- />
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setShowRejectDialog(false)}
- disabled={isLoading}
- >
- Cancel
- </Button>
- <Button
- onClick={handleSubmitReject}
- disabled={isLoading || !rejectComment.trim()}
- variant="destructive"
- >
- Confirm Rejection
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </div>
- )
-}
-
-// 코멘트 추가 함수 인터페이스
-interface VendorPQReviewPageIntegratedProps {
- data: PQGroupData[];
- onCommentAdded: (comment: PendingComment) => void;
-}
-
-// 통합된 VendorPQReviewPage 컴포넌트
-function VendorPQReviewPageIntegrated({ data, onCommentAdded }: VendorPQReviewPageIntegratedProps) {
- const { toast } = useToast();
-
- // 파일 다운로드 함수 - 서버 액션 사용
- const handleFileDownload = async (filePath: string, fileName: string) => {
- try {
- toast({
- title: "Download Started",
- description: `Preparing ${fileName} for download...`,
- });
-
- // 서버 액션 호출
- const result = await downloadFileAction(filePath);
-
- if (!result.ok || !result.data) {
- throw new Error(result.error || 'Failed to download file');
- }
-
- // Base64 디코딩하여 Blob 생성
- const binaryString = atob(result.data.content);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
-
- // Blob 생성 및 다운로드
- const blob = new Blob([bytes.buffer], { type: result.data.mimeType });
- const url = URL.createObjectURL(blob);
-
- // 다운로드 링크 생성 및 클릭
- const a = document.createElement('a');
- a.href = url;
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
-
- // 정리
- URL.revokeObjectURL(url);
- document.body.removeChild(a);
-
- toast({
- title: "Download Complete",
- description: `${fileName} downloaded successfully`,
- });
- } catch (error) {
- console.error('Download error:', error);
- toast({
- title: "Download Error",
- description: error instanceof Error ? error.message : "Failed to download file",
- variant: "destructive"
- });
- }
- };
-
- return (
- <div className="space-y-4">
- {data.map((group) => (
- <Collapsible key={group.groupName} defaultOpen>
- <CollapsibleTrigger asChild>
- <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded">
- <h2 className="font-semibold text-lg">{group.groupName}</h2>
- <Button variant="ghost" size="sm" className="p-0 h-7 w-7">
- <ChevronsUpDown className="h-4 w-4" />
- </Button>
- </div>
- </CollapsibleTrigger>
-
- <CollapsibleContent>
- <Card className="mt-2 p-4">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-[60px]">Code</TableHead>
- <TableHead>Check Point</TableHead>
- <TableHead>Answer</TableHead>
- <TableHead className="w-[180px]">Attachments</TableHead>
- <TableHead className="w-[60px] text-center">Comments</TableHead>
- </TableRow>
- </TableHeader>
-
- <TableBody>
- {group.items.map((item) => (
- <TableRow key={item.criteriaId}>
- <TableCell className="font-medium">{item.code}</TableCell>
- <TableCell>{item.checkPoint}</TableCell>
-
- <TableCell>
- {item.answer ? (
- <p className="whitespace-pre-wrap text-sm">
- {item.answer}
- </p>
- ) : (
- <p className="text-sm text-muted-foreground">(no answer)</p>
- )}
- </TableCell>
-
- <TableCell>
- {item.attachments.length > 0 ? (
- <ul className="list-none space-y-1">
- {item.attachments.map((file) => (
- <li key={file.attachId} className="text-sm flex items-center">
- <button
- className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]"
- onClick={() => handleFileDownload(file.filePath, file.fileName)}
- >
- <Download className="h-3 w-3 mr-1 flex-shrink-0" />
- <span className="truncate">{file.fileName}</span>
- </button>
- </li>
- ))}
- </ul>
- ) : (
- <p className="text-sm text-muted-foreground">(none)</p>
- )}
- </TableCell>
-
- <TableCell className="text-center">
- <ItemCommentButton
- item={item}
- onCommentAdded={onCommentAdded}
- />
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </Card>
- </CollapsibleContent>
- </Collapsible>
- ))}
- </div>
- );
-}
-
-// 항목 코멘트 버튼 컴포넌트 props
-interface ItemCommentButtonProps {
- item: any; // 항목 데이터
- onCommentAdded: (comment: PendingComment) => void;
-}
-
-// 항목별 코멘트 버튼 컴포넌트 (기존 로그 표시 + 메모리에 새 코멘트 저장)
-function ItemCommentButton({ item, onCommentAdded }: ItemCommentButtonProps) {
- const { toast } = useToast();
- const [open, setOpen] = React.useState(false);
- const [logs, setLogs] = React.useState<ReviewLog[]>([]);
- const [newComment, setNewComment] = React.useState("");
- const [isLoading, setIsLoading] = React.useState(false);
- const [hasComments, setHasComments] = React.useState(false);
-
- // If there's no answerId, item wasn't answered
- if (!item.answerId) {
- return <p className="text-xs text-muted-foreground">N/A</p>;
- }
-
- // 기존 로그 가져오기
- const fetchLogs = React.useCallback(async () => {
- try {
- setIsLoading(true);
- const res = await getItemReviewLogsAction({ answerId: item.answerId });
-
- if (res.ok && res.data) {
- setLogs(res.data);
- // 코멘트 존재 여부 설정
- setHasComments(res.data.length > 0);
- } else {
- console.error("Error response:", res.error);
- toast({ title: "Error", description: res.error, variant: "destructive" });
- }
- } catch (error) {
- console.error("Fetch error:", error);
- toast({ title: "Error", description: String(error), variant: "destructive" });
- } finally {
- setIsLoading(false);
- }
- }, [item.answerId, toast]);
-
- // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용)
- React.useEffect(() => {
- const checkComments = async () => {
- try {
- const res = await getItemReviewLogsAction({ answerId: item.answerId });
- if (res.ok && res.data) {
- setHasComments(res.data.length > 0);
- }
- } catch (error) {
- console.error("Error checking comments:", error);
- }
- };
-
- checkComments();
- }, [item.answerId]);
-
- // open 상태가 변경될 때 로그 가져오기
- React.useEffect(() => {
- if (open) {
- fetchLogs();
- }
- }, [open, fetchLogs]);
-
- // 다이얼로그 열기
- const handleButtonClick = React.useCallback(() => {
- setOpen(true);
- }, []);
-
- // 다이얼로그 상태 변경
- const handleOpenChange = React.useCallback((nextOpen: boolean) => {
- setOpen(nextOpen);
- }, []);
-
- // 코멘트 추가 처리 (메모리에만 저장)
- const handleAddComment = React.useCallback(() => {
- if (!newComment.trim()) return;
-
- setIsLoading(true);
-
- // 새 코멘트 생성
- const pendingComment: PendingComment = {
- answerId: item.answerId,
- checkPoint: item.checkPoint,
- code: item.code,
- comment: newComment.trim(),
- createdAt: new Date()
- };
-
- // 부모 컴포넌트에 전달
- onCommentAdded(pendingComment);
-
- // 상태 초기화
- setNewComment("");
- setOpen(false);
- setIsLoading(false);
- }, [item, newComment, onCommentAdded]);
-
- return (
- <>
- <Button variant="ghost" size="sm" onClick={handleButtonClick}>
- <MessagesSquare
- className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`}
- />
- </Button>
-
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>{item.checkPoint}</DialogTitle>
- <DialogDescription>
- Review existing comments and add new ones
- </DialogDescription>
- </DialogHeader>
-
- {/* 기존 로그 섹션 */}
- <div className="max-h-[200px] overflow-y-auto space-y-2">
- {isLoading ? (
- <div className="flex justify-center p-4">
- <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
- </div>
- ) : logs.length > 0 ? (
- <div className="space-y-2">
- <h3 className="text-sm font-medium">Previous Comments:</h3>
- {logs.map((log) => (
- <div key={log.id} className="p-2 border rounded text-sm">
- <p className="font-medium">{log.reviewerName}</p>
- <p>{log.reviewerComment}</p>
- <p className="text-xs text-muted-foreground">
- {formatDate(log.createdAt, "KR")}
- </p>
- </div>
- ))}
- </div>
- ) : (
- <p className="text-sm text-muted-foreground">No previous comments yet.</p>
- )}
- </div>
-
- {/* 구분선 */}
- {/* <Separator /> */}
-
- {/* 새 코멘트 추가 섹션 */}
- <div className="space-y-2 mt-2">
- <div className="flex items-center justify-between">
- {/* <h3 className="text-sm font-medium">Add New Comment:</h3> */}
- {/* <p className="text-xs text-muted-foreground">
- Comments will be saved when you click "Request Changes"
- </p> */}
- </div>
- <Textarea
- placeholder="Add your comment..."
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- className="min-h-[100px]"
- />
- <Button
- onClick={handleAddComment}
- disabled={isLoading || !newComment.trim()}
- >
- Add Comment
- </Button>
- </div>
- </DialogContent>
- </Dialog>
- </>
- );
-} \ No newline at end of file
diff --git a/components/pq/pq-review-table.tsx b/components/pq/pq-review-table.tsx
deleted file mode 100644
index ce30bac0..00000000
--- a/components/pq/pq-review-table.tsx
+++ /dev/null
@@ -1,344 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { ChevronsUpDown, MessagesSquare, Download, Loader2 } from "lucide-react"
-
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { Card } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { PQGroupData } from "@/lib/pq/service"
-import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Textarea } from "@/components/ui/textarea"
-import { addReviewCommentAction, getItemReviewLogsAction } from "@/lib/pq/service"
-import { useToast } from "@/hooks/use-toast"
-import { formatDate } from "@/lib/utils"
-import { downloadFileAction } from "@/lib/downloadFile"
-import { useSession } from "next-auth/react"
-
-interface ReviewLog {
- id: number
- reviewerComment: string
- reviewerName: string | null
- createdAt: Date
-}
-
-interface VendorPQReviewPageProps {
- data: PQGroupData[];
- onCommentAdded?: () => void; // 코멘트 추가 콜백
-}
-
-export default function VendorPQReviewPage({ data, onCommentAdded }: VendorPQReviewPageProps) {
- const { toast } = useToast()
-
- // 파일 다운로드 함수 - 서버 액션 사용
- const handleFileDownload = async (filePath: string, fileName: string) => {
- try {
- toast({
- title: "Download Started",
- description: `Preparing ${fileName} for download...`,
- });
-
- // 서버 액션 호출
- const result = await downloadFileAction(filePath);
-
- if (!result.ok || !result.data) {
- throw new Error(result.error || 'Failed to download file');
- }
-
- // Base64 디코딩하여 Blob 생성
- const binaryString = atob(result.data.content);
- const bytes = new Uint8Array(binaryString.length);
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i);
- }
-
- // Blob 생성 및 다운로드
- const blob = new Blob([bytes.buffer], { type: result.data.mimeType });
- const url = URL.createObjectURL(blob);
-
- // 다운로드 링크 생성 및 클릭
- const a = document.createElement('a');
- a.href = url;
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
-
- // 정리
- URL.revokeObjectURL(url);
- document.body.removeChild(a);
-
- toast({
- title: "Download Complete",
- description: `${fileName} downloaded successfully`,
- });
- } catch (error) {
- console.error('Download error:', error);
- toast({
- title: "Download Error",
- description: error instanceof Error ? error.message : "Failed to download file",
- variant: "destructive"
- });
- }
- };
-
- return (
- <div className="space-y-4">
- {data.map((group) => (
- <Collapsible key={group.groupName} defaultOpen>
- <CollapsibleTrigger asChild>
- <div className="flex items-center justify-between cursor-pointer p-3 bg-muted rounded">
- <h2 className="font-semibold text-lg">{group.groupName}</h2>
- <Button variant="ghost" size="sm" className="p-0 h-7 w-7">
- <ChevronsUpDown className="h-4 w-4" />
- </Button>
- </div>
- </CollapsibleTrigger>
-
- <CollapsibleContent>
- <Card className="mt-2 p-4">
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-[60px]">Code</TableHead>
- <TableHead>Check Point</TableHead>
- <TableHead>Answer</TableHead>
- <TableHead className="w-[180px]">Attachments</TableHead>
- <TableHead className="w-[60px] text-center">Comments</TableHead>
- </TableRow>
- </TableHeader>
-
- <TableBody>
- {group.items.map((item) => (
- <TableRow key={item.criteriaId}>
- <TableCell className="font-medium">{item.code}</TableCell>
- <TableCell>{item.checkPoint}</TableCell>
-
- <TableCell>
- {item.answer ? (
- <p className="whitespace-pre-wrap text-sm">
- {item.answer}
- </p>
- ) : (
- <p className="text-sm text-muted-foreground">(no answer)</p>
- )}
- </TableCell>
-
- <TableCell>
- {item.attachments.length > 0 ? (
- <ul className="list-none space-y-1">
- {item.attachments.map((file) => (
- <li key={file.attachId} className="text-sm flex items-center">
- <button
- className="text-blue-600 hover:text-blue-800 hover:underline flex items-center truncate max-w-[160px]"
- onClick={() => handleFileDownload(file.filePath, file.fileName)}
- >
- <Download className="h-3 w-3 mr-1 flex-shrink-0" />
- <span className="truncate">{file.fileName}</span>
- </button>
- </li>
- ))}
- </ul>
- ) : (
- <p className="text-sm text-muted-foreground">(none)</p>
- )}
- </TableCell>
-
- <TableCell className="text-center">
- <ItemReviewButton
- answerId={item.answerId ?? undefined}
- checkPoint={item.checkPoint}
- onCommentAdded={onCommentAdded}
- />
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </Card>
- </CollapsibleContent>
- </Collapsible>
- ))}
- </div>
- )
-}
-
-interface ItemReviewButtonProps {
- answerId?: number;
- checkPoint: string; // Check Point 추가
- onCommentAdded?: () => void;
-}
-
-/**
- * A button that opens a dialog to show logs + add new comment for a single item (vendorPqCriteriaAnswers).
- */
-function ItemReviewButton({ answerId, checkPoint, onCommentAdded }: ItemReviewButtonProps) {
- const { toast } = useToast();
- const [open, setOpen] = React.useState(false);
- const [logs, setLogs] = React.useState<ReviewLog[]>([]);
- const [newComment, setNewComment] = React.useState("");
- const [isLoading, setIsLoading] = React.useState(false);
- const [hasComments, setHasComments] = React.useState(false);
- const { data: session } = useSession()
- const reviewerName = session?.user?.name || "Unknown Reviewer"
- const reviewerId = session?.user?.id
-
- // If there's no answerId, item wasn't answered
- if (!answerId) {
- return <p className="text-xs text-muted-foreground">N/A</p>;
- }
-
- // fetchLogs 함수를 useCallback으로 메모이제이션
- const fetchLogs = React.useCallback(async () => {
- try {
- setIsLoading(true);
- const res = await getItemReviewLogsAction({ answerId });
-
- if (res.ok && res.data) {
- setLogs(res.data);
- // 코멘트 존재 여부 설정
- setHasComments(res.data.length > 0);
- } else {
- console.error("Error response:", res.error);
- toast({ title: "Error", description: res.error, variant: "destructive" });
- }
- } catch (error) {
- console.error("Fetch error:", error);
- toast({ title: "Error", description: String(error), variant: "destructive" });
- } finally {
- setIsLoading(false);
- }
- }, [answerId, toast]);
-
- // 초기 로드 시 코멘트 존재 여부 확인 (아이콘 색상용)
- React.useEffect(() => {
- const checkComments = async () => {
- try {
- const res = await getItemReviewLogsAction({ answerId });
- if (res.ok && res.data) {
- setHasComments(res.data.length > 0);
- }
- } catch (error) {
- console.error("Error checking comments:", error);
- }
- };
-
- checkComments();
- }, [answerId]);
-
- // open 상태가 변경될 때 로그 가져오기
- React.useEffect(() => {
- if (open) {
- fetchLogs();
- }
- }, [open, fetchLogs]);
-
- // 버튼 클릭 핸들러 - 다이얼로그 열기
- const handleButtonClick = React.useCallback(() => {
- setOpen(true);
- }, []);
-
- // 다이얼로그 상태 변경 핸들러
- const handleOpenChange = React.useCallback((nextOpen: boolean) => {
- setOpen(nextOpen);
- }, []);
-
- // 코멘트 추가 핸들러
- const handleAddComment = React.useCallback(async () => {
- try {
- setIsLoading(true);
-
- const res = await addReviewCommentAction({
- answerId,
- comment: newComment,
- reviewerName,
- });
-
- if (res.ok) {
- toast({ title: "Comment added", description: "New review comment saved" });
- setNewComment("");
- setHasComments(true); // 코멘트 추가 성공 시 상태 업데이트
-
- // 코멘트가 추가되었음을 부모 컴포넌트에 알림
- if (onCommentAdded) {
- onCommentAdded();
- }
-
- // 로그 다시 가져오기
- fetchLogs();
- } else {
- toast({ title: "Error", description: res.error, variant: "destructive" });
- }
- } catch (error) {
- toast({ title: "Error", description: String(error), variant: "destructive" });
- } finally {
- setIsLoading(false);
- }
- }, [answerId, newComment, onCommentAdded, fetchLogs, toast]);
-
- return (
- <>
- <Button variant="ghost" size="sm" onClick={handleButtonClick}>
- <MessagesSquare
- className={`h-4 w-4 ${hasComments ? 'text-blue-600' : ''}`}
- />
- </Button>
-
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>{checkPoint} Comments</DialogTitle>
- </DialogHeader>
-
- {/* Logs section */}
- <div className="max-h-[200px] overflow-y-auto space-y-2">
- {isLoading ? (
- <div className="flex justify-center p-4">
- <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
- </div>
- ) : logs.length > 0 ? (
- logs.map((log) => (
- <div key={log.id} className="p-2 border rounded text-sm">
- <p className="font-medium">{log.reviewerName}</p>
- <p>{log.reviewerComment}</p>
- <p className="text-xs text-muted-foreground">
- {formatDate(log.createdAt, "KR")}
- </p>
- </div>
- ))
- ) : (
- <p className="text-sm text-muted-foreground">No comments yet.</p>
- )}
- </div>
-
- {/* Add new comment */}
- <div className="space-y-2">
- <Textarea
- placeholder="Add a new comment..."
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- />
- <Button
- size="sm"
- onClick={handleAddComment}
- disabled={isLoading || !newComment.trim()}
- >
- Add Comment
- </Button>
- </div>
- </DialogContent>
- </Dialog>
- </>
- );
-} \ No newline at end of file
diff --git a/components/pq/project-select-wrapper.tsx b/components/pq/project-select-wrapper.tsx
deleted file mode 100644
index 1405ab02..00000000
--- a/components/pq/project-select-wrapper.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { type Project } from "@/lib/rfqs/service"
-import { ProjectSelector } from "./project-select"
-
-interface ProjectSelectorWrapperProps {
- selectedProjectId?: number | null
-}
-
-export function ProjectSelectorWrapper({ selectedProjectId }: ProjectSelectorWrapperProps) {
- const router = useRouter()
-
- const handleProjectSelect = (project: Project | null) => {
- if (project && project.id) {
- router.push(`/evcp/pq-criteria/${project.id}`)
- } else {
- // 프로젝트가 null인 경우 (선택 해제)
- router.push(`/evcp/pq-criteria`)
- }
- }
-
- return (
- <div className="w-[400px]">
- <ProjectSelector
- selectedProjectId={selectedProjectId}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트를 선택하세요"
- showClearOption={true}
- clearOptionText="일반 PQ 보기"
- />
- </div>
- )
-} \ No newline at end of file
diff --git a/components/pq/project-select.tsx b/components/pq/project-select.tsx
deleted file mode 100644
index 0d6e6445..00000000
--- a/components/pq/project-select.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Check, ChevronsUpDown, X } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
-import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator } from "@/components/ui/command"
-import { cn } from "@/lib/utils"
-import { getProjects, type Project } from "@/lib/rfqs/service"
-
-interface ProjectSelectorProps {
- selectedProjectId?: number | null;
- onProjectSelect: (project: Project | null) => void;
- placeholder?: string;
- showClearOption?: boolean;
- clearOptionText?: string;
-}
-
-export function ProjectSelector({
- selectedProjectId,
- onProjectSelect,
- placeholder = "프로젝트 선택...",
- showClearOption = true,
- clearOptionText = "일반 PQ 보기"
-}: ProjectSelectorProps) {
- const [open, setOpen] = React.useState(false)
- const [searchTerm, setSearchTerm] = React.useState("")
- const [projects, setProjects] = React.useState<Project[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
- const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
-
- // 모든 프로젝트 데이터 로드 (한 번만)
- React.useEffect(() => {
- async function loadAllProjects() {
- setIsLoading(true);
- try {
- const allProjects = await getProjects();
- setProjects(allProjects);
-
- // 초기 선택된 프로젝트가 있으면 설정
- if (selectedProjectId) {
- const selected = allProjects.find(p => p.id === selectedProjectId);
- if (selected) {
- setSelectedProject(selected);
- }
- }
- } catch (error) {
- console.error("프로젝트 목록 로드 오류:", error);
- } finally {
- setIsLoading(false);
- }
- }
-
- loadAllProjects();
- }, [selectedProjectId]);
-
- // 클라이언트 측에서 검색어로 필터링
- const filteredProjects = React.useMemo(() => {
- if (!searchTerm.trim()) return projects;
-
- const lowerSearch = searchTerm.toLowerCase();
- return projects.filter(
- project =>
- project.projectCode.toLowerCase().includes(lowerSearch) ||
- project.projectName.toLowerCase().includes(lowerSearch)
- );
- }, [projects, searchTerm]);
-
- // 프로젝트 선택 처리
- const handleSelectProject = (project: Project) => {
- setSelectedProject(project);
- onProjectSelect(project);
- setOpen(false);
- };
-
- // 선택 해제 처리
- const handleClearSelection = () => {
- setSelectedProject(null);
- onProjectSelect(null);
- setOpen(false);
- };
-
- return (
- <div className="space-y-1">
- {/* 선택된 프로젝트 정보 표시 (선택된 경우에만) */}
- {selectedProject && (
- <div className="flex items-center justify-between px-2">
- <div className="flex flex-col">
- <div className="text-sm font-medium">{selectedProject.projectCode}</div>
- <div className="text-xs text-muted-foreground truncate max-w-[300px]">
- {selectedProject.projectName}
- </div>
- </div>
- <Button
- variant="ghost"
- size="sm"
- className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
- onClick={handleClearSelection}
- >
- <X className="h-4 w-4" />
- <span className="sr-only">선택 해제</span>
- </Button>
- </div>
- )}
-
- {/* 셀렉터 컴포넌트 */}
- <Popover open={open} onOpenChange={setOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={open}
- className="w-full justify-between"
- >
- {selectedProject ? "프로젝트 변경..." : placeholder}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
-
- <PopoverContent className="w-[400px] p-0">
- <Command>
- <CommandInput
- placeholder="프로젝트 코드/이름 검색..."
- onValueChange={setSearchTerm}
- />
- <CommandList className="max-h-[300px]">
- <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
-
- {showClearOption && selectedProject && (
- <>
- <CommandGroup>
- <CommandItem
- onSelect={handleClearSelection}
- className="text-blue-600 font-medium"
- >
- {clearOptionText}
- </CommandItem>
- </CommandGroup>
- <CommandSeparator />
- </>
- )}
-
- {isLoading ? (
- <div className="py-6 text-center text-sm">로딩 중...</div>
- ) : (
- <CommandGroup>
- {filteredProjects.map((project) => (
- <CommandItem
- key={project.id}
- value={`${project.projectCode} ${project.projectName}`}
- onSelect={() => handleSelectProject(project)}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- selectedProject?.id === project.id
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- <span className="font-medium">{project.projectCode}</span>
- <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span>
- </CommandItem>
- ))}
- </CommandGroup>
- )}
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- </div>
- );
-} \ No newline at end of file
diff --git a/components/ui/file-list.tsx b/components/ui/file-list.tsx
index b6ade7f5..b469a08e 100644
--- a/components/ui/file-list.tsx
+++ b/components/ui/file-list.tsx
@@ -23,7 +23,7 @@ export const FileListItem = React.forwardRef<
<div
ref={ref}
className={cn(
- "grid gap-4 rounded-xl border bg-card p-4 text-card-foreground shadow",
+ "flex items-center justify-between gap-4 rounded-xl border bg-card p-4 text-card-foreground shadow",
className
)}
{...props}
@@ -64,7 +64,7 @@ export const FileListInfo = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
- <div ref={ref} className={cn("grid flex-1 gap-1", className)} {...props} />
+ <div ref={ref} className={cn("flex flex-1 items-center gap-2", className)} {...props} />
))
FileListInfo.displayName = "FileListInfo"
diff --git a/components/vendor-regular-registrations/additional-info-dialog.tsx b/components/vendor-regular-registrations/additional-info-dialog.tsx
new file mode 100644
index 00000000..fbd60515
--- /dev/null
+++ b/components/vendor-regular-registrations/additional-info-dialog.tsx
@@ -0,0 +1,502 @@
+"use client";
+
+import * as React from "react";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Plus, Trash2 } from "lucide-react";
+import { toast } from "sonner";
+import {
+ saveVendorBusinessContacts,
+ saveVendorAdditionalInfo,
+ fetchVendorRegistrationStatus
+} from "@/lib/vendor-regular-registrations/service";
+
+// 업무담당자 정보 스키마
+const businessContactSchema = z.object({
+ contactType: z.enum(["sales", "design", "delivery", "quality", "tax_invoice"]),
+ contactName: z.string().min(1, "담당자명은 필수입니다"),
+ position: z.string().min(1, "직급은 필수입니다"),
+ department: z.string().min(1, "부서는 필수입니다"),
+ responsibility: z.string().min(1, "담당업무는 필수입니다"),
+ email: z.string().email("올바른 이메일 형식이 아닙니다"),
+});
+
+// 추가정보 스키마
+const additionalInfoSchema = z.object({
+ businessType: z.string().optional(),
+ industryType: z.string().optional(),
+ companySize: z.string().optional(),
+ revenue: z.string().optional(),
+ factoryEstablishedDate: z.string().optional(),
+ preferredContractTerms: z.string().optional(),
+});
+
+// 전체 폼 스키마
+const formSchema = z.object({
+ businessContacts: z.array(businessContactSchema).min(5, "모든 업무담당자 정보를 입력해주세요"),
+ additionalInfo: additionalInfoSchema,
+});
+
+type FormData = z.infer<typeof formSchema>;
+
+interface AdditionalInfoDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ vendorId: number;
+ onSave?: () => void;
+}
+
+const contactTypes = [
+ { value: "sales", label: "영업", required: true },
+ { value: "design", label: "설계", required: true },
+ { value: "delivery", label: "납기", required: true },
+ { value: "quality", label: "품질", required: true },
+ { value: "tax_invoice", label: "세금계산서", required: true },
+];
+
+const businessTypes = [
+ { value: "manufacturing", label: "제조업" },
+ { value: "trading", label: "무역업" },
+ { value: "service", label: "서비스업" },
+ { value: "construction", label: "건설업" },
+ { value: "other", label: "기타" },
+];
+
+const industryTypes = [
+ { value: "shipbuilding", label: "조선업" },
+ { value: "marine", label: "해양플랜트" },
+ { value: "energy", label: "에너지" },
+ { value: "automotive", label: "자동차" },
+ { value: "other", label: "기타" },
+];
+
+const companySizes = [
+ { value: "large", label: "대기업" },
+ { value: "medium", label: "중견기업" },
+ { value: "small", label: "중소기업" },
+ { value: "startup", label: "스타트업" },
+];
+
+export function AdditionalInfoDialog({
+ open,
+ onOpenChange,
+ vendorId,
+ onSave,
+}: AdditionalInfoDialogProps) {
+ const [saving, setSaving] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const form = useForm<FormData>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ businessContacts: contactTypes.map(type => ({
+ contactType: type.value as any,
+ contactName: "",
+ position: "",
+ department: "",
+ responsibility: "",
+ email: "",
+ })),
+ additionalInfo: {
+ businessType: "",
+ industryType: "",
+ companySize: "",
+ revenue: "",
+ factoryEstablishedDate: "",
+ preferredContractTerms: "",
+ },
+ },
+ });
+
+ // 기존 데이터 로드
+ const loadExistingData = async () => {
+ if (!vendorId || !open) return;
+
+ setLoading(true);
+ try {
+ const result = await fetchVendorRegistrationStatus(vendorId);
+ if (result.success && result.data) {
+ const { businessContacts, additionalInfo } = result.data;
+
+ // 업무담당자 데이터 설정
+ const contactsData = contactTypes.map(type => {
+ const existingContact = businessContacts.find(c => c.contactType === type.value);
+ return existingContact ? {
+ contactType: existingContact.contactType,
+ contactName: existingContact.contactName,
+ position: existingContact.position,
+ department: existingContact.department,
+ responsibility: existingContact.responsibility,
+ email: existingContact.email,
+ } : {
+ contactType: type.value as any,
+ contactName: "",
+ position: "",
+ department: "",
+ responsibility: "",
+ email: "",
+ };
+ });
+
+ // 추가정보 데이터 설정
+ const additionalData = {
+ businessType: additionalInfo?.businessType || "",
+ industryType: additionalInfo?.industryType || "",
+ companySize: additionalInfo?.companySize || "",
+ revenue: additionalInfo?.revenue || "",
+ factoryEstablishedDate: additionalInfo?.factoryEstablishedDate
+ ? new Date(additionalInfo.factoryEstablishedDate).toISOString().split('T')[0]
+ : "",
+ preferredContractTerms: additionalInfo?.preferredContractTerms || "",
+ };
+
+ // 폼 데이터 업데이트
+ form.reset({
+ businessContacts: contactsData,
+ additionalInfo: additionalData,
+ });
+ }
+ } catch (error) {
+ console.error("Error loading existing data:", error);
+ toast.error("기존 데이터를 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 다이얼로그가 열릴 때 데이터 로드
+ React.useEffect(() => {
+ loadExistingData();
+ }, [vendorId, open]);
+
+ const handleSave = async (data: FormData) => {
+ setSaving(true);
+ try {
+ // 업무담당자 정보 저장
+ const contactsResult = await saveVendorBusinessContacts(vendorId, data.businessContacts);
+ if (!contactsResult.success) {
+ throw new Error(contactsResult.error);
+ }
+
+ // 추가정보 저장
+ const additionalResult = await saveVendorAdditionalInfo(vendorId, data.additionalInfo);
+ if (!additionalResult.success) {
+ throw new Error(additionalResult.error);
+ }
+
+ toast.success("추가정보가 저장되었습니다.");
+ onSave?.();
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Error saving additional info:", error);
+ toast.error(error instanceof Error ? error.message : "추가정보 저장 중 오류가 발생했습니다.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>추가정보 입력</DialogTitle>
+ <p className="text-sm text-muted-foreground">
+ 정규업체 등록을 위한 추가정보를 입력해주세요. <span className="text-red-500">*</span> 표시는 필수 입력 항목입니다.
+ </p>
+ </DialogHeader>
+
+ {loading ? (
+ <div className="p-8 text-center">
+ <div className="text-sm text-muted-foreground">데이터를 불러오는 중...</div>
+ </div>
+ ) : (
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSave)}>
+ <Tabs defaultValue="contacts" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="contacts">업무담당자 정보</TabsTrigger>
+ <TabsTrigger value="additional">추가정보</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="contacts" className="space-y-4">
+ <div className="space-y-4">
+ {contactTypes.map((contactType, index) => (
+ <Card key={contactType.value}>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-lg flex items-center gap-2">
+ {contactType.label} 담당자
+ <Badge variant="destructive" className="text-xs">필수</Badge>
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.contactName`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="담당자명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.position`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>직급 *</FormLabel>
+ <FormControl>
+ <Input placeholder="직급 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.department`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>부서 *</FormLabel>
+ <FormControl>
+ <Input placeholder="부서명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.email`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email *</FormLabel>
+ <FormControl>
+ <Input placeholder="이메일 입력" type="email" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name={`businessContacts.${index}.responsibility`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당업무 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="담당업무 상세 입력"
+ className="h-20"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ </TabsContent>
+
+ <TabsContent value="additional" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>회사 추가정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="additionalInfo.businessType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>사업유형</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="사업유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {businessTypes.map((type) => (
+ <SelectItem key={type.value} value={type.value}>
+ {type.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="additionalInfo.industryType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>산업유형</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="산업유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {industryTypes.map((type) => (
+ <SelectItem key={type.value} value={type.value}>
+ {type.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="additionalInfo.companySize"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기업규모</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="기업규모 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {companySizes.map((size) => (
+ <SelectItem key={size.value} value={size.value}>
+ {size.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="additionalInfo.revenue"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>매출액 (억원)</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="매출액 입력"
+ type="number"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="additionalInfo.factoryEstablishedDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공장설립일</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="YYYY-MM-DD"
+ type="date"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="additionalInfo.preferredContractTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선호계약조건</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="선호하는 계약조건을 상세히 입력해주세요"
+ className="h-32"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+
+ <DialogFooter className="mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={saving}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={saving}>
+ {saving ? "저장 중..." : "저장"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ )}
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/components/vendor-regular-registrations/document-status-dialog.tsx b/components/vendor-regular-registrations/document-status-dialog.tsx
new file mode 100644
index 00000000..f8e2e1cd
--- /dev/null
+++ b/components/vendor-regular-registrations/document-status-dialog.tsx
@@ -0,0 +1,265 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { FileText, Download, CheckCircle, XCircle, Clock } from "lucide-react";
+import { toast } from "sonner";
+
+import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig";
+import {
+ documentStatusColumns,
+ contractAgreementColumns,
+} from "@/config/vendorRegularRegistrationsColumnsConfig";
+import { downloadFile } from "@/lib/file-download";
+
+interface DocumentStatusDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ registration: VendorRegularRegistration | null;
+}
+
+const StatusIcon = ({ status }: { status: string | boolean }) => {
+ if (typeof status === "boolean") {
+ return status ? (
+ <CheckCircle className="w-4 h-4 text-green-600" />
+ ) : (
+ <XCircle className="w-4 h-4 text-red-500" />
+ );
+ }
+
+ switch (status) {
+ case "completed":
+ return <CheckCircle className="w-4 h-4 text-green-600" />;
+ case "reviewing":
+ return <Clock className="w-4 h-4 text-yellow-600" />;
+ case "not_submitted":
+ default:
+ return <XCircle className="w-4 h-4 text-red-500" />;
+ }
+};
+
+const StatusBadge = ({ status }: { status: string | boolean }) => {
+ if (typeof status === "boolean") {
+ return (
+ <Badge variant={status ? "default" : "destructive"}>
+ {status ? "제출완료" : "미제출"}
+ </Badge>
+ );
+ }
+
+ const statusConfig = {
+ completed: { label: "완료", variant: "default" as const },
+ reviewing: { label: "검토중", variant: "secondary" as const },
+ not_submitted: { label: "미제출", variant: "destructive" as const },
+ };
+
+ const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.not_submitted;
+
+ return <Badge variant={config.variant}>{config.label}</Badge>;
+};
+
+export function DocumentStatusDialog({
+ open,
+ onOpenChange,
+ registration,
+}: DocumentStatusDialogProps) {
+ if (!registration) return null;
+
+ // 파일 다운로드 핸들러
+ const handleFileDownload = async (docKey: string, fileIndex: number = 0) => {
+ try {
+ const files = registration.documentFiles[docKey as keyof typeof registration.documentFiles];
+ if (!files || files.length === 0) {
+ toast.error("다운로드할 파일이 없습니다.");
+ return;
+ }
+
+ const file = files[fileIndex];
+ if (!file) {
+ toast.error("파일을 찾을 수 없습니다.");
+ return;
+ }
+
+ // filePath와 fileName 추출
+ const filePath = file.filePath || file.path;
+ const fileName = file.originalFileName || file.fileName || file.name;
+
+ if (!filePath || !fileName) {
+ toast.error("파일 정보가 올바르지 않습니다.");
+ return;
+ }
+
+ console.log(`📥 파일 다운로드 시작:`, { filePath, fileName, docKey });
+
+ // downloadFile 함수를 사용하여 파일 다운로드
+ const result = await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error("파일 다운로드 오류:", error);
+ toast.error(`파일 다운로드 실패: ${error}`);
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`✅ 파일 다운로드 성공:`, { fileName, fileSize });
+ }
+ });
+
+ if (!result.success) {
+ console.error("파일 다운로드 실패:", result.error);
+ }
+ } catch (error) {
+ console.error("파일 다운로드 중 오류 발생:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 문서/자료 접수 현황 - {registration.companyName}
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
+ <div>
+ <span className="text-sm font-medium text-gray-600">업체명:</span>
+ <span className="ml-2">{registration.companyName}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">사업자번호:</span>
+ <span className="ml-2">{registration.businessNumber}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">대표자:</span>
+ <span className="ml-2">{registration.representative || "-"}</span>
+ </div>
+ <div>
+ <span className="text-sm font-medium text-gray-600">현재상태:</span>
+ <Badge className="ml-2">{registration.status}</Badge>
+ </div>
+ </div>
+
+ {/* 문서 제출 현황 */}
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold">문서 제출 현황</h3>
+ </div>
+ <div className="border rounded-lg">
+ <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
+ <div>문서유형</div>
+ <div>상태</div>
+ <div>제출일자</div>
+ <div>액션</div>
+ </div>
+ {documentStatusColumns.map((doc) => {
+ const isSubmitted = registration.documentSubmissions[
+ doc.key as keyof typeof registration.documentSubmissions
+ ] as boolean;
+
+ return (
+ <div
+ key={doc.key}
+ className="grid grid-cols-4 gap-4 p-4 border-t items-center"
+ >
+ <div className="flex items-center gap-2">
+ <StatusIcon status={isSubmitted} />
+ {doc.label}
+ </div>
+ <div>
+ <StatusBadge status={isSubmitted} />
+ </div>
+ <div className="text-sm text-gray-600">
+ {isSubmitted ? "2024.01.01" : "-"}
+ </div>
+ <div>
+ {isSubmitted && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => handleFileDownload(doc.key)}
+ >
+ <Download className="w-4 h-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* 계약 동의 현황 */}
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <h3 className="text-lg font-semibold">계약 동의 현황</h3>
+ </div>
+ <div className="border rounded-lg">
+ <div className="grid grid-cols-4 gap-4 p-4 bg-gray-50 font-medium text-sm">
+ <div>계약유형</div>
+ <div>상태</div>
+ <div>서약일자</div>
+ <div>액션</div>
+ </div>
+ {contractAgreementColumns.map((agreement) => {
+ const status = registration.contractAgreements[
+ agreement.key as keyof typeof registration.contractAgreements
+ ] as string;
+
+ return (
+ <div
+ key={agreement.key}
+ className="grid grid-cols-4 gap-4 p-4 border-t items-center"
+ >
+ <div className="flex items-center gap-2">
+ <StatusIcon status={status} />
+ {agreement.label}
+ </div>
+ <div>
+ <StatusBadge status={status} />
+ </div>
+ <div className="text-sm text-gray-600">
+ {status === "completed" ? "2024.01.01" : "-"}
+ </div>
+ <div>
+ {status === "completed" && (
+ <Button size="sm" variant="outline">
+ <Download className="w-4 h-4 mr-1" />
+ 다운로드
+ </Button>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* 추가 정보 */}
+ <div>
+ <h3 className="text-lg font-semibold mb-4">추가 정보</h3>
+ <div className="p-4 border rounded-lg">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <StatusIcon status={registration.additionalInfo} />
+ <span>추가 정보 등록</span>
+ </div>
+ <StatusBadge status={registration.additionalInfo} />
+ </div>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/components/vendor-regular-registrations/skip-reason-dialog.tsx b/components/vendor-regular-registrations/skip-reason-dialog.tsx
new file mode 100644
index 00000000..f47d8929
--- /dev/null
+++ b/components/vendor-regular-registrations/skip-reason-dialog.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { useState } from "react";
+import { toast } from "sonner";
+
+interface SkipReasonDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ description: string;
+ onConfirm: (reason: string) => Promise<void>;
+ loading: boolean;
+}
+
+export function SkipReasonDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ onConfirm,
+ loading,
+}: SkipReasonDialogProps) {
+ const [reason, setReason] = useState("");
+
+ const handleConfirm = async () => {
+ if (!reason.trim()) {
+ toast.error("Skip 사유를 입력해주세요.");
+ return;
+ }
+
+ try {
+ await onConfirm(reason.trim());
+ setReason(""); // 성공 시 초기화
+ onOpenChange(false);
+ } catch (error) {
+ // 에러는 상위 컴포넌트에서 처리
+ }
+ };
+
+ const handleCancel = () => {
+ setReason("");
+ onOpenChange(false);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <p className="text-sm text-muted-foreground">
+ {description}
+ </p>
+
+ <div className="space-y-2">
+ <Label htmlFor="reason">Skip 사유</Label>
+ <Textarea
+ id="reason"
+ placeholder="Skip 사유를 입력해주세요..."
+ value={reason}
+ onChange={(e) => setReason(e.target.value)}
+ rows={4}
+ disabled={loading}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={handleCancel}
+ disabled={loading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleConfirm}
+ disabled={loading || !reason.trim()}
+ >
+ {loading ? "처리 중..." : "확인"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}