summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-30 10:08:53 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-30 10:08:53 +0000
commit2c02afd48a4d9276a4f5c132e088540a578d0972 (patch)
treee5efdd3f48fad33681c139a4c58481f4514fb38e
parent19794b32a6e3285fdeda7519ededdce451966f3d (diff)
(대표님) 폼리스트, spreadjs 관련 변경사항, 벤더문서 뷰 쿼리 수정, 이메일 템플릿 추가 등
-rw-r--r--app/[lng]/evcp/(evcp)/(master-data)/form-list/page.tsx (renamed from app/[lng]/evcp/(evcp)/(eng)/form-list/page.tsx)0
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx18
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx5
-rw-r--r--components/form-data/form-data-table.tsx189
-rw-r--r--components/form-data/spreadJS-dialog copy.tsx1514
-rw-r--r--components/form-data/spreadJS-dialog.tsx725
-rw-r--r--db/schema/rfqLastTBE.ts2
-rw-r--r--db/schema/vendorDocu.ts2
-rw-r--r--lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx161
-rw-r--r--lib/evaluation-criteria/stat.ts413
-rw-r--r--lib/forms/stat.ts209
-rw-r--r--lib/mail/templates/pq.hbs3
-rw-r--r--lib/mail/templates/vendor-rejected.hbs195
-rw-r--r--lib/projects/service.ts23
-rw-r--r--lib/rfq-last/attachment/vendor-response-table.tsx1
-rw-r--r--lib/rfq-last/service.ts87
-rw-r--r--lib/rfq-last/validations.ts2
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx8
-rw-r--r--lib/sedp/get-form-tags.ts147
-rw-r--r--lib/tbe-last/service.ts2
-rw-r--r--lib/vendor-document-list/ship/send-to-shi-button.tsx70
-rw-r--r--lib/vendors/table/request-pq-dialog.tsx13
22 files changed, 3365 insertions, 424 deletions
diff --git a/app/[lng]/evcp/(evcp)/(eng)/form-list/page.tsx b/app/[lng]/evcp/(evcp)/(master-data)/form-list/page.tsx
index 7f04cc3e..7f04cc3e 100644
--- a/app/[lng]/evcp/(evcp)/(eng)/form-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(master-data)/form-list/page.tsx
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx
index 20281f9c..6dcbf018 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx
@@ -107,6 +107,22 @@ export default async function RfqLayout({
const dueDateStatus = rfq?.dueDate ? getDueDateStatus(rfq.dueDate) : null;
+ const getRfqCategory = (rfqCode: string | null | undefined): string => {
+ if (!rfqCode || rfqCode.length === 0) return 'itb'; // 기본값
+
+ const firstChar = rfqCode[0].toUpperCase();
+ switch (firstChar) {
+ case 'I':
+ return 'itb';
+ case 'R':
+ return 'rfq';
+ case 'F':
+ return 'general';
+ default:
+ return 'itb'; // 기본값
+ }
+ };
+
return (
<>
<div className="container py-6">
@@ -122,7 +138,7 @@ export default async function RfqLayout({
: `견적 상세 관리 ${rfq.rfqCode ?? ""}`
: "Loading RFQ..."}
</h2>
- <Link href={`/${lng}/evcp/rfq-last`} passHref>
+ <Link href={`/${lng}/evcp/rfq-last?rfqCategory=${getRfqCategory(rfq?.rfqCode)}`} passHref>
<Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors">
<ArrowLeft className="mr-1 h-4 w-4" />
<span>견적 목록으로 돌아가기</span>
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
index af5a8d95..ab63c14f 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/page.tsx
@@ -64,8 +64,11 @@ export default async function RfqPage(props: RfqPageProps) {
// nuqs 기반 파라미터 파싱
const search = searchParamsRfqCache.parse(searchParams);
+
// 탭별 데이터 카운트 가져오기
const tabCounts = await getTabCounts();
+
+ console.log(search.rfqCategory ,"search.rfqCategory ")
// 현재 선택된 탭 (URL 파라미터에서 가져오거나 기본값 'all')
const currentTab = search.rfqCategory || "itb";
@@ -89,7 +92,7 @@ export default async function RfqPage(props: RfqPageProps) {
</div>
{/* 탭 컨테이너 */}
- <Tabs defaultValue="itb"className="w-full flex-1 flex flex-col overflow-hidden">
+ <Tabs defaultValue={currentTab} className="w-full flex-1 flex flex-col overflow-hidden">
<TabsList className="grid w-full max-w-[600px] grid-cols-3 flex-shrink-0">
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index a2645679..591ba66a 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -19,14 +19,19 @@ import {
Upload,
Plus,
Tag,
- TagsIcon,
+ TagsIcon,
FileOutput,
Clipboard,
Send,
GitCompareIcon,
RefreshCcw,
Trash2,
- Eye
+ Eye,
+ FileText,
+ Target,
+ CheckCircle2,
+ AlertCircle,
+ Clock
} from "lucide-react";
import { toast } from "sonner";
import {
@@ -54,6 +59,13 @@ import { SEDPCompareDialog } from "./sedp-compare-dialog";
import { DeleteFormDataDialog } from "./delete-form-data-dialog";
import { TemplateViewDialog } from "./spreadJS-dialog";
import { fetchTemplateFromSEDP } from "@/lib/forms/sedp-actions";
+import { FormStatusByVendor, getFormStatusByVendor } from "@/lib/forms/stat";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle
+} from "@/components/ui/card";
interface GenericData {
[key: string]: unknown;
@@ -99,6 +111,33 @@ export default function DynamicTable({
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<GenericData[]>([]);
+ const [formStats, setFormStats] = React.useState<FormStatusByVendor | null>(null);
+ const [isLoadingStats, setIsLoadingStats] = React.useState(true);
+
+
+ React.useEffect(() => {
+ const fetchFormStats = async () => {
+ try {
+ setIsLoadingStats(true);
+ // getFormStatusByVendor 서버 액션 직접 호출
+ const data = await getFormStatusByVendor(projectId, formCode);
+
+ if (data && data.length > 0) {
+ setFormStats(data[0]);
+ }
+ } catch (error) {
+ console.error("Failed to fetch form stats:", error);
+ toast.error("통계 데이터를 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoadingStats(false);
+ }
+ };
+
+ if (projectId && formCode) {
+ fetchFormStats();
+ }
+ }, [projectId, formCode]);
+
// Update tableData when dataJSON changes
React.useEffect(() => {
setTableData(dataJSON);
@@ -180,7 +219,7 @@ export default function DynamicTable({
setPackageCode('');
}
};
-
+
getPackageCode();
}, [contractItemId])
// Get project code when component mounts
@@ -633,6 +672,142 @@ export default function DynamicTable({
return (
<>
+ <div className="mb-6">
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
+ {/* Tag Count */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Total Tags
+ </CardTitle>
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ formStats?.tagCount || 0
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Total Tag Count
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* Completion Rate */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Completion
+ </CardTitle>
+ <Target className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ `${formStats?.completionRate || 0}%`
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {formStats ? `${formStats.completedFields} / ${formStats.totalFields}` : '-'}
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* Completed Fields */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Completed
+ </CardTitle>
+ <CheckCircle2 className="h-4 w-4 text-green-600" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ formStats?.completedFields || 0
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Completed Fields
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* Remaining Fields */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Remaining
+ </CardTitle>
+ <Clock className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ (formStats?.totalFields || 0) - (formStats?.completedFields || 0)
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Remaining Fields
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* Upcoming (7 days) */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Upcoming
+ </CardTitle>
+ <AlertCircle className="h-4 w-4 text-yellow-600" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-yellow-600">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ formStats?.upcomingCount || 0
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Due in 7 Days
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* Overdue */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">
+ Overdue
+ </CardTitle>
+ <AlertCircle className="h-4 w-4 text-red-600" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-red-600">
+ {isLoadingStats ? (
+ <span className="animate-pulse">-</span>
+ ) : (
+ formStats?.overdueCount || 0
+ )}
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Overdue
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+
<ClientDataTable
data={tableData}
columns={columns}
@@ -661,8 +836,8 @@ export default function DynamicTable({
<Button variant="outline" size="sm" disabled={isAnyOperationPending}>
{(isSyncingTags || isLoadingTags) ? (
<Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- ):
- <TagsIcon className="size-4" />}
+ ) :
+ <TagsIcon className="size-4" />}
{t("buttons.tagOperations")}
</Button>
</DropdownMenuTrigger>
@@ -679,8 +854,8 @@ export default function DynamicTable({
{t("buttons.getTags")}
</DropdownMenuItem>
)}
- <DropdownMenuItem
- onClick={() => setAddTagDialogOpen(true)}
+ <DropdownMenuItem
+ onClick={() => setAddTagDialogOpen(true)}
disabled={isAnyOperationPending || isAddTagDisabled}
>
<Plus className="mr-2 h-4 w-4" />
diff --git a/components/form-data/spreadJS-dialog copy.tsx b/components/form-data/spreadJS-dialog copy.tsx
new file mode 100644
index 00000000..d001463e
--- /dev/null
+++ b/components/form-data/spreadJS-dialog copy.tsx
@@ -0,0 +1,1514 @@
+"use client";
+
+import * as React from "react";
+import dynamic from "next/dynamic";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { GenericData } from "./export-excel-form";
+import * as GC from "@mescius/spread-sheets";
+import { toast } from "sonner";
+import { updateFormDataInDB } from "@/lib/forms/services";
+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";
+import { setupSpreadJSLicense } from "@/lib/spread-js/license-utils";
+
+const SpreadSheets = dynamic(
+ () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets),
+ {
+ ssr: false,
+ loading: () => (
+ <div className="flex items-center justify-center h-full">
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading SpreadSheets...
+ </div>
+ )
+ }
+);
+
+// 도메인별 라이선스 설정
+if (typeof window !== 'undefined') {
+ setupSpreadJSLicense(GC);
+}
+
+interface TemplateItem {
+ TMPL_ID: string;
+ NAME: string;
+ TMPL_TYPE: string;
+ SPR_LST_SETUP: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: Array<string>;
+ CONTENT?: string;
+ DATA_SHEETS: Array<{
+ SHEET_NAME: string;
+ REG_TYPE_ID: string;
+ MAP_CELL_ATT: Array<{
+ ATT_ID: string;
+ IN: string;
+ }>;
+ }>;
+ };
+ GRD_LST_SETUP: {
+ REG_TYPE_ID: string;
+ SPR_ITM_IDS: Array<string>;
+ ATTS: Array<{}>;
+ };
+ SPR_ITM_LST_SETUP: {
+ ACT_SHEET: string;
+ HIDN_SHEETS: Array<string>;
+ CONTENT?: string;
+ DATA_SHEETS: Array<{
+ SHEET_NAME: string;
+ REG_TYPE_ID: string;
+ MAP_CELL_ATT: Array<{
+ ATT_ID: string;
+ IN: string;
+ }>;
+ }>;
+ };
+}
+
+interface ValidationError {
+ cellAddress: string;
+ attId: string;
+ value: any;
+ expectedType: ColumnType;
+ message: string;
+}
+
+interface CellMapping {
+ attId: string;
+ cellAddress: string;
+ isEditable: boolean;
+ dataRowIndex?: number;
+}
+
+interface TemplateViewDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ templateData: TemplateItem[] | any;
+ selectedRow?: GenericData;
+ tableData?: GenericData[];
+ formCode: string;
+ columnsJSON: DataTableColumnJSON[]
+ contractItemId: number;
+ editableFieldsMap?: Map<string, string[]>;
+ 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,
+ templateData,
+ selectedRow,
+ tableData = [],
+ formCode,
+ contractItemId,
+ columnsJSON,
+ editableFieldsMap = new Map(),
+ onUpdateSuccess
+}: TemplateViewDialogProps) {
+ const [hostStyle, setHostStyle] = React.useState({
+ width: '100%',
+ height: '100%'
+ });
+
+ const [isPending, setIsPending] = React.useState(false);
+ const [hasChanges, setHasChanges] = React.useState(false);
+ const [currentSpread, setCurrentSpread] = React.useState<any>(null);
+ const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]);
+ const [isClient, setIsClient] = React.useState(false);
+ const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null);
+ 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;
+
+ let templates: TemplateItem[];
+ if (Array.isArray(templateData)) {
+ templates = templateData as TemplateItem[];
+ } else {
+ templates = [templateData as TemplateItem];
+ }
+
+ const validTemplates = templates.filter(isValidTemplate);
+ setAvailableTemplates(validTemplates);
+
+ if (validTemplates.length > 0 && !selectedTemplateId) {
+ const firstTemplate = validTemplates[0];
+ const templateTypeToSet = determineTemplateType(firstTemplate);
+ setSelectedTemplateId(firstTemplate.TMPL_ID);
+ setTemplateType(templateTypeToSet);
+ }
+ }, [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);
+ setTemplateType(templateTypeToSet);
+ setHasChanges(false);
+ setValidationErrors([]);
+
+ 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의 경우에만 전역 editableFields 사용
+ if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO) {
+ if (!editableFieldsMap.has(selectedRow.TAG_NO)) {
+ return [];
+ }
+ return editableFieldsMap.get(selectedRow.TAG_NO) || [];
+ }
+
+ // SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음
+ return [];
+ }, [templateType, selectedRow?.TAG_NO, editableFieldsMap]);
+
+
+const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => {
+ const columnConfig = columnsJSON.find(col => col.key === attId);
+ if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) {
+ return false;
+ }
+
+ if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") {
+ return false;
+ }
+
+ if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
+ // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단
+ if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
+ return false;
+ }
+
+ const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || [];
+ if (!rowEditableFields.includes(attId)) {
+ return false;
+ }
+
+ if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) {
+ return false;
+ }
+ return true;
+ }
+
+ // SPREAD_ITEM의 경우 기존 로직 유지
+ if (templateType === 'SPREAD_ITEM') {
+ return editableFields.includes(attId);
+ }
+
+ return true;
+}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거
+
+const editableFieldsCount = React.useMemo(() => {
+ if (templateType === 'SPREAD_ITEM') {
+ // SPREAD_ITEM의 경우 기존 로직 유지
+ return cellMappings.filter(m => m.isEditable).length;
+ }
+
+ if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
+ // 각 행별로 편집 가능한 필드 수를 계산
+ let totalEditableCount = 0;
+
+ tableData.forEach((rowData, rowIndex) => {
+ cellMappings.forEach(mapping => {
+ if (mapping.dataRowIndex === rowIndex) {
+ if (isFieldEditable(mapping.attId, rowData)) {
+ totalEditableCount++;
+ }
+ }
+ });
+ });
+
+ return totalEditableCount;
+ }
+
+ return cellMappings.filter(m => m.isEditable).length;
+}, [cellMappings, templateType, tableData, isFieldEditable]);
+
+ // 🚀 배치 처리 함수들
+ 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 createCellStyle = React.useCallback((activeSheet: any, row: number, col: number, isEditable: boolean) => {
+ // 기존 스타일 가져오기 (없으면 새로 생성)
+ const existingStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style();
+
+ // backColor만 수정
+ if (isEditable) {
+ existingStyle.backColor = "#bbf7d0";
+ } else {
+ existingStyle.backColor = "#e5e7eb";
+ // 읽기 전용일 때만 텍스트 색상 변경 (선택사항)
+ existingStyle.foreColor = "#4b5563";
+ }
+
+ return existingStyle;
+ }, []);
+
+ const setBatchStyles = React.useCallback((
+ activeSheet: any,
+ stylesToSet: Array<{row: number, col: number, isEditable: boolean}>
+ ) => {
+ console.log(`🎨 Setting ${stylesToSet.length} styles in batch`);
+
+ // 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장)
+ stylesToSet.forEach(({row, col, isEditable}) => {
+ try {
+ const cell = activeSheet.getCell(row, col);
+ const style = createCellStyle(activeSheet, row, col, isEditable);
+
+ 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;
+
+ const match = address.match(/^([A-Z]+)(\d+)$/);
+ if (!match) return null;
+
+ const [, colStr, rowStr] = match;
+
+ let col = 0;
+ for (let i = 0; i < colStr.length; i++) {
+ col = col * 26 + (colStr.charCodeAt(i) - 65 + 1);
+ }
+ col -= 1;
+
+ const row = parseInt(rowStr) - 1;
+ return { row, col };
+ };
+
+ const getCellAddress = (row: number, col: number): string => {
+ let colStr = '';
+ let colNum = col;
+ while (colNum >= 0) {
+ colStr = String.fromCharCode((colNum % 26) + 65) + colStr;
+ colNum = Math.floor(colNum / 26) - 1;
+ }
+ return colStr + (row + 1);
+ };
+
+ const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => {
+ if (value === undefined || value === null || value === "") {
+ return null;
+ }
+
+ switch (columnType) {
+ case "NUMBER":
+ if (isNaN(Number(value))) {
+ return "Value must be a valid number";
+ }
+ break;
+ case "LIST":
+ if (options && !options.includes(String(value))) {
+ return `Value must be one of: ${options.join(", ")}`;
+ }
+ break;
+ case "STRING":
+ break;
+ default:
+ break;
+ }
+
+ return null;
+ };
+
+ const validateAllData = React.useCallback(() => {
+ if (!currentSpread || !selectedTemplate) return [];
+
+ const activeSheet = currentSpread.getActiveSheet();
+ const errors: ValidationError[] = [];
+
+ cellMappings.forEach(mapping => {
+ const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
+ if (!columnConfig) return;
+
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (!cellPos) return;
+
+ 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
+ });
+ }
+ });
+
+ setValidationErrors(errors);
+ return errors;
+ }, [currentSpread, selectedTemplate, cellMappings, columnsJSON]);
+
+
+
+ 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);
+
+ const safeOptions = options
+ .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)
+ .slice(0, 20);
+
+ if (safeOptions.length === 0) {
+ console.warn(`⚠️ No valid options found, skipping`);
+ return;
+ }
+
+ const optionsString = safeOptions.join(',');
+
+ for (let i = 0; i < rowCount; i++) {
+ try {
+ const targetRow = cellPos.row + i;
+
+ // 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성
+ const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBoxCellType.items(safeOptions);
+ comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+
+ // 🔧 DataValidation 설정
+ const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString);
+ 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(`✅ Dropdown applied to [${targetRow}, ${cellPos.col}] with ${safeOptions.length} options`);
+
+ } catch (cellError) {
+ console.warn(`⚠️ Failed to apply dropdown to row ${cellPos.row + i}:`, cellError);
+ }
+ }
+
+ 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) => {
+ 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;
+ }
+ }, []);
+
+ 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;
+ }
+ }, []);
+
+ 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);
+ });
+ }, []);
+
+ // 🚀 최적화된 GRD_LIST 생성
+ // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함)
+const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => {
+ console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze');
+
+ 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[] = [];
+
+ ensureColumnCapacity(activeSheet, startCol + visibleColumns.length);
+ ensureRowCapacity(activeSheet, dataStartRow + tableData.length);
+
+ // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용)
+ const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC');
+ let freezeColumnCount = 0;
+
+ if (tagDescColumnIndex !== -1) {
+ // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1)
+ freezeColumnCount = startCol + tagDescColumnIndex + 1;
+ console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`);
+ } else {
+ // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼)
+ const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO');
+ if (tagNoColumnIndex !== -1) {
+ freezeColumnCount = startCol + tagNoColumnIndex + 1;
+ console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`);
+ }
+ }
+
+ // 헤더 생성
+ 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[]; // 편집 가능한 행만 추적
+ }> = [];
+
+ visibleColumns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+
+ // 드롭다운 설정을 위한 편집 가능한 행 찾기
+ if (column.type === "LIST" && column.options) {
+ const editableRows: number[] = [];
+ tableData.forEach((rowData, rowIndex) => {
+ if (isFieldEditable(column.key, rowData)) { // 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); // rowData 전달
+ const value = rowData[column.key];
+
+ mappings.push({
+ attId: column.key,
+ cellAddress: getCellAddress(targetRow, targetCol),
+ isEditable: cellEditable,
+ dataRowIndex: rowIndex
+ });
+
+ allValues.push({
+ row: targetRow,
+ col: targetCol,
+ value: value ?? null
+ });
+
+ allStyles.push({
+ row: targetRow,
+ col: targetCol,
+ isEditable: cellEditable
+ });
+ });
+ });
+
+ // 🚀 배치로 값과 스타일 설정
+ setBatchValues(activeSheet, allValues);
+ setBatchStyles(activeSheet, allStyles);
+
+ // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만)
+ 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);
+ }
+ });
+ } catch (error) {
+ console.error(`❌ Dropdown config failed for column ${col}:`, error);
+ }
+ });
+
+ // 🧊 틀고정 설정
+ if (freezeColumnCount > 0) {
+ try {
+ activeSheet.frozenColumnCount(freezeColumnCount);
+ activeSheet.frozenRowCount(1); // 헤더 행도 고정
+
+ console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`);
+
+ // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항)
+ for (let col = 0; col < freezeColumnCount; col++) {
+ for (let row = 0; row <= tableData.length; row++) {
+ try {
+ const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style();
+
+ if (row === 0) {
+ // 헤더는 기존 스타일 유지
+ continue;
+ } else {
+ // 데이터 셀에 고정 구분선 추가
+ if (col === freezeColumnCount - 1) {
+ currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium);
+ activeSheet.setStyle(row, col, currentStyle);
+ }
+ }
+ } catch (styleError) {
+ console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError);
+ }
+ }
+ }
+ } catch (freezeError) {
+ console.error('❌ Failed to apply freeze:', freezeError);
+ }
+ }
+
+ setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData);
+
+ console.log(`✅ Optimized GRD_LIST created with freeze:`);
+ console.log(` - Total mappings: ${mappings.length}`);
+ console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`);
+ console.log(` - Dropdown configs: ${dropdownConfigs.length}`);
+ console.log(` - Frozen columns: ${freezeColumnCount}`);
+
+ return mappings;
+}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]);
+
+ const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => {
+ console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`);
+
+ // 🔧 시트 보호 완전 해제 후 편집 권한 설정
+ activeSheet.options.isProtected = false;
+
+ // 🔧 편집 가능한 셀들을 위한 강화된 설정
+ 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(activeSheet, cellPos.row, cellPos.col, 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(activeSheet, cellPos.row, cellPos.col, false);
+ activeSheet.setStyle(cellPos.row, cellPos.col, readonlyStyle);
+ }
+ } catch (error) {
+ console.error(`❌ Error processing cell ${mapping.cellAddress}:`, error);
+ }
+ });
+
+ // 🛡️ 시트 보호 재설정 (편집 허용 모드로)
+ activeSheet.options.isProtected = false;
+ activeSheet.options.protectionOptions = {
+ allowSelectLockedCells: true,
+ allowSelectUnlockedCells: true,
+ allowSort: false,
+ allowFilter: false,
+ allowEditObjects: true, // ✅ 편집 객체 허용
+ allowResizeRows: false,
+ allowResizeColumns: false,
+ allowFormatCells: false,
+ allowInsertRows: false,
+ allowInsertColumns: false,
+ allowDeleteRows: false,
+ allowDeleteColumns: false
+ };
+
+ // 🎯 변경 감지 이벤트
+ const changeEvents = [
+ GC.Spread.Sheets.Events.CellChanged,
+ GC.Spread.Sheets.Events.ValueChanged,
+ GC.Spread.Sheets.Events.ClipboardPasted
+ ];
+
+ changeEvents.forEach(eventType => {
+ activeSheet.bind(eventType, () => {
+ console.log(`📝 ${eventType} detected`);
+ setHasChanges(true);
+ });
+ });
+
+ // 🚫 편집 시작 권한 확인
+ 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;
+ });
+
+ if (!exactMapping) {
+ console.log(`ℹ️ No mapping found for [${info.row}, ${info.col}] - allowing edit`);
+ return; // 매핑이 없으면 허용
+ }
+
+ 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`);
+ info.cancel = true;
+ return;
+ }
+
+ // SPREAD_LIST/GRD_LIST 개별 행 SHI 확인
+ if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) {
+ const dataRowIndex = exactMapping.dataRowIndex;
+ if (dataRowIndex >= 0 && dataRowIndex < tableData.length) {
+ const rowData = tableData[dataRowIndex];
+ if (rowData?.shi === "OUT" || rowData?.shi === null ) {
+ console.log(`🚫 Row ${dataRowIndex} is in SHI mode`);
+ toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`);
+ info.cancel = true;
+ return;
+ }
+ }
+ }
+
+ 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}, 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) return;
+
+ const columnConfig = columnsJSON.find(col => col.key === exactMapping.attId);
+ if (columnConfig) {
+ const cellValue = activeSheet.getValue(info.row, info.col);
+ const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options);
+ const cell = activeSheet.getCell(info.row, info.col);
+
+ if (errorMessage) {
+ // 🚨 에러 스타일 적용
+ const errorStyle = new GC.Spread.Sheets.Style();
+ errorStyle.backColor = "#fef2f2";
+ errorStyle.foreColor = "#dc2626";
+ errorStyle.borderLeft = new GC.Spread.Sheets.LineBorder("#dc2626", GC.Spread.Sheets.LineStyle.thick);
+ 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}`, { duration: 5000 });
+ } else {
+ // ✅ 정상 스타일 복원
+ const normalStyle = createCellStyle(activeSheet, info.row, info.col, exactMapping.isEditable);
+ activeSheet.setStyle(info.row, info.col, normalStyle);
+ cell.locked(!exactMapping.isEditable);
+ }
+ }
+
+ setHasChanges(true);
+ });
+
+ console.log(`🛡️ Protection configured. Editable cells: ${mappings.filter(m => m.isEditable).length}`);
+ }, [templateType, tableData, createCellStyle, validateCellValue, columnsJSON]);
+
+ // 🚀 최적화된 initSpread
+ const initSpread = React.useCallback(async (spread: any, template?: TemplateItem) => {
+ const workingTemplate = template || selectedTemplate;
+ 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 {
+ let activeSheet = getSafeActiveSheet(spread, 'initSpread');
+ if (!activeSheet) {
+ throw new Error('Failed to get initial activeSheet');
+ }
+
+ activeSheet.options.isProtected = false;
+ let mappings: CellMapping[] = [];
+
+ 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');
+
+ updateProgress('Processing table data...', 50, 100);
+ mappings = createGrdListTableOptimized(sheet, workingTemplate);
+
+ } else {
+ 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 || !dataSheets) {
+ throw new Error(`No template content found for ${workingTemplate.NAME}`);
+ }
+
+ 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 (templateType === 'SPREAD_LIST' && tableData.length > 0) {
+ updateProgress('Processing data rows...', 60, 100);
+
+ const activeSheetName = workingTemplate.SPR_LST_SETUP?.ACT_SHEET;
+
+ const matchingDataSheets = dataSheets.filter(ds =>
+ ds.SHEET_NAME === activeSheetName
+ );
+
+ if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
+ spread.setActiveSheet(activeSheetName);
+ }
+
+ matchingDataSheets.forEach(dataSheet => {
+ if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) {
+ dataSheet.MAP_CELL_ATT.forEach((mapping: any) => {
+ const { ATT_ID, IN } = mapping;
+ 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);
+ const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET;
+
+ const matchingDataSheets = dataSheets.filter(ds =>
+ ds.SHEET_NAME === activeSheetName
+ );
+
+ if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
+ spread.setActiveSheet(activeSheetName);
+ }
+
+ matchingDataSheets.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(activeSheet, cellPos.row, cellPos.col, 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);
+
+ 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 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, updateProgress, getSafeActiveSheet, createGrdListTableOptimized, setBatchValues, setBatchStyles, setupSheetProtectionAndEvents, setCellMappings, createCellStyle, isFieldEditable, columnsJSON, setupOptimizedListValidation, parseCellAddress, ensureRowCapacity, getCellAddress]);
+
+ 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.`);
+ return;
+ }
+
+ try {
+ setIsPending(true);
+ const activeSheet = currentSpread.getActiveSheet();
+
+ if (templateType === 'SPREAD_ITEM' && selectedRow) {
+ const dataToSave = { ...selectedRow };
+
+ cellMappings.forEach(mapping => {
+ if (mapping.isEditable) {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (cellPos) {
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ dataToSave[mapping.attId] = cellValue;
+ }
+ }
+ });
+
+ dataToSave.TAG_NO = selectedRow.TAG_NO;
+
+ const { success, message } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ dataToSave
+ );
+
+ if (!success) {
+ toast.error(message);
+ return;
+ }
+
+ toast.success("Changes saved successfully!");
+ onUpdateSuccess?.(dataToSave);
+
+ } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) {
+ console.log('🔍 Starting batch save process...');
+
+ const updatedRows: GenericData[] = [];
+ let saveCount = 0;
+ let checkedCount = 0;
+
+ for (let i = 0; i < tableData.length; i++) {
+ const originalRow = tableData[i];
+ const dataToSave = { ...originalRow };
+ let hasRowChanges = false;
+
+ console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`);
+
+ cellMappings.forEach(mapping => {
+ if (mapping.dataRowIndex === i && mapping.isEditable) {
+ checkedCount++;
+
+ // 🔧 isFieldEditable과 동일한 로직 사용
+ const rowData = tableData[i];
+ const fieldEditable = isFieldEditable(mapping.attId, rowData);
+
+ console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`);
+
+ if (fieldEditable) {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (cellPos) {
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ const originalValue = originalRow[mapping.attId];
+
+ // 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리)
+ const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim();
+ const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim();
+
+ console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`);
+
+ if (normalizedCellValue !== normalizedOriginalValue) {
+ dataToSave[mapping.attId] = cellValue;
+ hasRowChanges = true;
+ console.log(` ✅ Change detected for ${mapping.attId}`);
+ }
+ }
+ }
+ }
+ });
+
+ if (hasRowChanges) {
+ console.log(`💾 Saving row ${i} with changes`);
+ dataToSave.TAG_NO = originalRow.TAG_NO;
+
+ try {
+ const { success, message } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ dataToSave
+ );
+
+ if (success) {
+ updatedRows.push(dataToSave);
+ saveCount++;
+ console.log(`✅ Row ${i} saved successfully`);
+ } else {
+ console.error(`❌ Failed to save row ${i}: ${message}`);
+ toast.error(`Failed to save row ${i + 1}: ${message}`);
+ updatedRows.push(originalRow); // 원본 데이터 유지
+ }
+ } catch (error) {
+ console.error(`❌ Error saving row ${i}:`, error);
+ toast.error(`Error saving row ${i + 1}`);
+ updatedRows.push(originalRow); // 원본 데이터 유지
+ }
+ } else {
+ updatedRows.push(originalRow);
+ console.log(`ℹ️ No changes in row ${i}`);
+ }
+ }
+
+ console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`);
+
+ if (saveCount > 0) {
+ toast.success(`${saveCount} rows saved successfully!`);
+ onUpdateSuccess?.(updatedRows);
+ } else {
+ console.warn(`⚠️ No changes detected despite hasChanges=${hasChanges}`);
+ toast.warning("No actual changes were found to save. Please check if the values were properly edited.");
+ }
+ }
+
+ setHasChanges(false);
+ setValidationErrors([]);
+
+ } catch (error) {
+ console.error("Error saving changes:", error);
+ toast.error("An unexpected error occurred while saving");
+ } finally {
+ setIsPending(false);
+ }
+ }, [
+ currentSpread,
+ hasChanges,
+ templateType,
+ selectedRow,
+ tableData,
+ formCode,
+ contractItemId,
+ onUpdateSuccess,
+ cellMappings,
+ columnsJSON,
+ validateAllData,
+ isFieldEditable // 🔧 의존성 추가
+ ]);
+
+ 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-[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>
+ <Select value={selectedTemplateId} onValueChange={handleTemplateChange}>
+ <SelectTrigger className="w-64">
+ <SelectValue placeholder="Select a template" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableTemplates.map(template => (
+ <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}>
+ {template.NAME} ({template.TMPL_TYPE})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </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)'
+ }
+ </span>
+ {templateType === 'SPREAD_ITEM' && selectedRow && (
+ <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span>
+ )}
+ {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && (
+ <span>• {dataCount} rows</span>
+ )}
+ {hasChanges && (
+ <span className="text-orange-600 font-medium">
+ • Unsaved changes
+ </span>
+ )}
+ {validationErrors.length > 0 && (
+ <span className="text-red-600 font-medium flex items-center">
+ <AlertTriangle className="w-4 h-4 mr-1" />
+ {validationErrors.length} validation errors
+ </span>
+ )}
+ </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>
+ Editable fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span>
+ Read-only fields
+ </span>
+ <span className="text-muted-foreground">
+ <span className="inline-block w-3 h-3 bg-red-100 border border-red-300 mr-1"></span>
+ Validation errors
+ </span>
+ {cellMappings.length > 0 && (
+ <span className="text-blue-600">
+ {editableFieldsCount} of {cellMappings.length} fields editable
+ </span>
+ )}
+ </div>
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+
+ <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}`}
+ workbookInitialized={initSpread}
+ hostStyle={hostStyle}
+ />
+ ) : (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ {!isClient ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading...
+ </>
+ ) : !selectedTemplate ? (
+ "No template available"
+ ) : !isDataValid ? (
+ `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available`
+ ) : (
+ "Template not ready"
+ )}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="flex-shrink-0">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={onClose}>
+ Close
+ </Button>
+
+ {hasChanges && (
+ <Button
+ variant="default"
+ onClick={handleSaveChanges}
+ disabled={isPending || validationErrors.length > 0}
+ >
+ {isPending ? (
+ <>
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Saving...
+ </>
+ ) : (
+ <>
+ <Save className="mr-2 h-4 w-4" />
+ Save Changes
+ </>
+ )}
+ </Button>
+ )}
+
+ {validationErrors.length > 0 && (
+ <Button
+ variant="outline"
+ onClick={validateAllData}
+ className="text-red-600 border-red-300 hover:bg-red-50"
+ >
+ <AlertTriangle className="mr-2 h-4 w-4" />
+ Check Errors ({validationErrors.length})
+ </Button>
+ )}
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx
index d001463e..91d5672c 100644
--- a/components/form-data/spreadJS-dialog.tsx
+++ b/components/form-data/spreadJS-dialog.tsx
@@ -107,9 +107,9 @@ interface LoadingProgressProps {
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]">
@@ -117,11 +117,11 @@ const LoadingProgress: React.FC<LoadingProgressProps> = ({ phase, progress, tota
<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
+ <div
className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
style={{ width: `${percentage}%` }}
/>
@@ -161,7 +161,7 @@ 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;
@@ -244,102 +244,102 @@ export function TemplateViewDialog({
}
return editableFieldsMap.get(selectedRow.TAG_NO) || [];
}
-
+
// SPREAD_LIST나 GRD_LIST의 경우 전역 editableFields는 사용하지 않음
return [];
}, [templateType, selectedRow?.TAG_NO, editableFieldsMap]);
-
-const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => {
- const columnConfig = columnsJSON.find(col => col.key === attId);
- if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) {
- return false;
- }
-
- if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") {
- return false;
- }
- if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
- // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단
- if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
+ const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => {
+ const columnConfig = columnsJSON.find(col => col.key === attId);
+ if (columnConfig?.shi === "OUT" || columnConfig?.shi === null) {
return false;
}
-
- const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || [];
- if (!rowEditableFields.includes(attId)) {
+
+ if (attId === "TAG_NO" || attId === "TAG_DESC" || attId === "status") {
return false;
}
-
- if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) {
- return false;
+
+ if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
+ // 각 행의 TAG_NO를 기준으로 편집 가능 여부 판단
+ if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) {
+ return false;
+ }
+
+ const rowEditableFields = editableFieldsMap.get(rowData.TAG_NO) || [];
+ if (!rowEditableFields.includes(attId)) {
+ return false;
+ }
+
+ if (rowData && (rowData.shi === "OUT" || rowData.shi === null)) {
+ return false;
+ }
+ return true;
}
+
+ // SPREAD_ITEM의 경우 기존 로직 유지
+ if (templateType === 'SPREAD_ITEM') {
+ return editableFields.includes(attId);
+ }
+
return true;
- }
+ }, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거
- // SPREAD_ITEM의 경우 기존 로직 유지
- if (templateType === 'SPREAD_ITEM') {
- return editableFields.includes(attId);
- }
+ const editableFieldsCount = React.useMemo(() => {
+ if (templateType === 'SPREAD_ITEM') {
+ // SPREAD_ITEM의 경우 기존 로직 유지
+ return cellMappings.filter(m => m.isEditable).length;
+ }
- return true;
-}, [templateType, columnsJSON, editableFieldsMap]); // editableFields 의존성 제거
+ if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
+ // 각 행별로 편집 가능한 필드 수를 계산
+ let totalEditableCount = 0;
-const editableFieldsCount = React.useMemo(() => {
- if (templateType === 'SPREAD_ITEM') {
- // SPREAD_ITEM의 경우 기존 로직 유지
- return cellMappings.filter(m => m.isEditable).length;
- }
-
- if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') {
- // 각 행별로 편집 가능한 필드 수를 계산
- let totalEditableCount = 0;
-
- tableData.forEach((rowData, rowIndex) => {
- cellMappings.forEach(mapping => {
- if (mapping.dataRowIndex === rowIndex) {
- if (isFieldEditable(mapping.attId, rowData)) {
- totalEditableCount++;
+ tableData.forEach((rowData, rowIndex) => {
+ cellMappings.forEach(mapping => {
+ if (mapping.dataRowIndex === rowIndex) {
+ if (isFieldEditable(mapping.attId, rowData)) {
+ totalEditableCount++;
+ }
}
- }
+ });
});
- });
-
- return totalEditableCount;
- }
-
- return cellMappings.filter(m => m.isEditable).length;
-}, [cellMappings, templateType, tableData, isFieldEditable]);
+
+ return totalEditableCount;
+ }
+
+ return cellMappings.filter(m => m.isEditable).length;
+ }, [cellMappings, templateType, tableData, isFieldEditable]);
// 🚀 배치 처리 함수들
const setBatchValues = React.useCallback((
- activeSheet: any,
- valuesToSet: Array<{row: number, col: number, value: any}>
+ 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}) => {
+
+ 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.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]);
@@ -356,7 +356,7 @@ const editableFieldsCount = React.useMemo(() => {
}
}
}
-
+
start = end + 1;
}
});
@@ -365,7 +365,7 @@ const editableFieldsCount = React.useMemo(() => {
const createCellStyle = React.useCallback((activeSheet: any, row: number, col: number, isEditable: boolean) => {
// 기존 스타일 가져오기 (없으면 새로 생성)
const existingStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style();
-
+
// backColor만 수정
if (isEditable) {
existingStyle.backColor = "#bbf7d0";
@@ -374,25 +374,25 @@ const editableFieldsCount = React.useMemo(() => {
// 읽기 전용일 때만 텍스트 색상 변경 (선택사항)
existingStyle.foreColor = "#4b5563";
}
-
+
return existingStyle;
}, []);
const setBatchStyles = React.useCallback((
- activeSheet: any,
- stylesToSet: Array<{row: number, col: number, isEditable: boolean}>
+ activeSheet: any,
+ stylesToSet: Array<{ row: number, col: number, isEditable: boolean }>
) => {
console.log(`🎨 Setting ${stylesToSet.length} styles in batch`);
-
+
// 🔧 개별 셀별로 스타일과 잠금 상태 설정 (편집 권한 보장)
- stylesToSet.forEach(({row, col, isEditable}) => {
+ stylesToSet.forEach(({ row, col, isEditable }) => {
try {
const cell = activeSheet.getCell(row, col);
const style = createCellStyle(activeSheet, row, col, isEditable);
-
+
activeSheet.setStyle(row, col, style);
cell.locked(!isEditable); // 편집 가능하면 잠금 해제
-
+
// 🆕 편집 가능한 셀에 기본 텍스트 에디터 설정
if (isEditable) {
const textCellType = new GC.Spread.Sheets.CellTypes.Text();
@@ -511,7 +511,7 @@ const editableFieldsCount = React.useMemo(() => {
for (let i = 0; i < rowCount; i++) {
try {
const targetRow = cellPos.row + i;
-
+
// 🔧 각 셀마다 새로운 ComboBox 인스턴스 생성
const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
comboBoxCellType.items(safeOptions);
@@ -525,7 +525,7 @@ const editableFieldsCount = React.useMemo(() => {
// ComboBox와 Validator 적용
activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType);
activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator);
-
+
// 🚀 중요: 셀 잠금 해제 및 편집 가능 설정
const cell = activeSheet.getCell(targetRow, cellPos.col);
cell.locked(false);
@@ -546,7 +546,7 @@ const editableFieldsCount = React.useMemo(() => {
const getSafeActiveSheet = React.useCallback((spread: any, functionName: string = 'unknown') => {
if (!spread) return null;
-
+
try {
let activeSheet = spread.getActiveSheet();
if (!activeSheet) {
@@ -568,7 +568,7 @@ const editableFieldsCount = React.useMemo(() => {
const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => {
try {
if (!activeSheet) return false;
-
+
const currentRowCount = activeSheet.getRowCount();
if (requiredRowCount > currentRowCount) {
const newRowCount = requiredRowCount + 10;
@@ -584,7 +584,7 @@ const editableFieldsCount = React.useMemo(() => {
const ensureColumnCapacity = React.useCallback((activeSheet: any, requiredColumnCount: number) => {
try {
if (!activeSheet) return false;
-
+
const currentColumnCount = activeSheet.getColumnCount();
if (requiredColumnCount > currentColumnCount) {
const newColumnCount = requiredColumnCount + 10;
@@ -606,206 +606,206 @@ const editableFieldsCount = React.useMemo(() => {
}, []);
// 🚀 최적화된 GRD_LIST 생성
- // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함)
-const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => {
- console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze');
-
- 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[] = [];
-
- ensureColumnCapacity(activeSheet, startCol + visibleColumns.length);
- ensureRowCapacity(activeSheet, dataStartRow + tableData.length);
-
- // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용)
- const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC');
- let freezeColumnCount = 0;
-
- if (tagDescColumnIndex !== -1) {
- // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1)
- freezeColumnCount = startCol + tagDescColumnIndex + 1;
- console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`);
- } else {
- // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼)
- const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO');
- if (tagNoColumnIndex !== -1) {
- freezeColumnCount = startCol + tagNoColumnIndex + 1;
- console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`);
+ // 🚀 최적화된 GRD_LIST 생성 (TAG_DESC 컬럼 틀고정 포함)
+ const createGrdListTableOptimized = React.useCallback((activeSheet: any, template: TemplateItem) => {
+ console.log('🚀 Creating optimized GRD_LIST table with TAG_DESC freeze');
+
+ 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[] = [];
+
+ ensureColumnCapacity(activeSheet, startCol + visibleColumns.length);
+ ensureRowCapacity(activeSheet, dataStartRow + tableData.length);
+
+ // 🧊 TAG_DESC 컬럼 위치 찾기 (틀고정용)
+ const tagDescColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_DESC');
+ let freezeColumnCount = 0;
+
+ if (tagDescColumnIndex !== -1) {
+ // TAG_DESC 컬럼까지 포함해서 고정 (startCol + tagDescColumnIndex + 1)
+ freezeColumnCount = startCol + tagDescColumnIndex + 1;
+ console.log(`🧊 TAG_DESC found at column index ${tagDescColumnIndex}, freezing ${freezeColumnCount} columns`);
+ } else {
+ // TAG_DESC가 없으면 TAG_NO까지만 고정 (일반적으로 첫 번째 컬럼)
+ const tagNoColumnIndex = visibleColumns.findIndex(col => col.key === 'TAG_NO');
+ if (tagNoColumnIndex !== -1) {
+ freezeColumnCount = startCol + tagNoColumnIndex + 1;
+ console.log(`🧊 TAG_NO found at column index ${tagNoColumnIndex}, freezing ${freezeColumnCount} columns`);
+ }
}
- }
- // 헤더 생성
- 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 headerStyle = new GC.Spread.Sheets.Style();
+ headerStyle.backColor = "#3b82f6";
+ headerStyle.foreColor = "#ffffff";
+ headerStyle.font = "bold 12px Arial";
+ headerStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center;
- // 🚀 데이터 배치 처리 준비
- 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[]; // 편집 가능한 행만 추적
- }> = [];
-
- visibleColumns.forEach((column, colIndex) => {
- const targetCol = startCol + colIndex;
-
- // 드롭다운 설정을 위한 편집 가능한 행 찾기
- if (column.type === "LIST" && column.options) {
- const editableRows: number[] = [];
- tableData.forEach((rowData, rowIndex) => {
- if (isFieldEditable(column.key, rowData)) { // rowData 전달
- editableRows.push(dataStartRow + rowIndex);
+ 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[]; // 편집 가능한 행만 추적
+ }> = [];
+
+ visibleColumns.forEach((column, colIndex) => {
+ const targetCol = startCol + colIndex;
+
+ // 드롭다운 설정을 위한 편집 가능한 행 찾기
+ if (column.type === "LIST" && column.options) {
+ const editableRows: number[] = [];
+ tableData.forEach((rowData, rowIndex) => {
+ if (isFieldEditable(column.key, rowData)) { // rowData 전달
+ editableRows.push(dataStartRow + rowIndex);
+ }
+ });
+
+ if (editableRows.length > 0) {
+ dropdownConfigs.push({
+ startRow: dataStartRow,
+ col: targetCol,
+ rowCount: tableData.length,
+ options: column.options,
+ editableRows: editableRows
+ });
}
- });
-
- if (editableRows.length > 0) {
- dropdownConfigs.push({
- startRow: dataStartRow,
+ }
+
+ tableData.forEach((rowData, rowIndex) => {
+ const targetRow = dataStartRow + rowIndex;
+ const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달
+ const value = rowData[column.key];
+
+ mappings.push({
+ attId: column.key,
+ cellAddress: getCellAddress(targetRow, targetCol),
+ isEditable: cellEditable,
+ dataRowIndex: rowIndex
+ });
+
+ allValues.push({
+ row: targetRow,
col: targetCol,
- rowCount: tableData.length,
- options: column.options,
- editableRows: editableRows
+ value: value ?? null
+ });
+
+ allStyles.push({
+ row: targetRow,
+ col: targetCol,
+ isEditable: cellEditable
});
- }
- }
-
- tableData.forEach((rowData, rowIndex) => {
- const targetRow = dataStartRow + rowIndex;
- const cellEditable = isFieldEditable(column.key, rowData); // rowData 전달
- const value = rowData[column.key];
-
- mappings.push({
- attId: column.key,
- cellAddress: getCellAddress(targetRow, targetCol),
- isEditable: cellEditable,
- dataRowIndex: rowIndex
- });
-
- allValues.push({
- row: targetRow,
- col: targetCol,
- value: value ?? null
- });
-
- allStyles.push({
- row: targetRow,
- col: targetCol,
- isEditable: cellEditable
});
});
- });
- // 🚀 배치로 값과 스타일 설정
- setBatchValues(activeSheet, allValues);
- setBatchStyles(activeSheet, allStyles);
+ // 🚀 배치로 값과 스타일 설정
+ setBatchValues(activeSheet, allValues);
+ setBatchStyles(activeSheet, allStyles);
- // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만)
- 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);
+ // 🎯 개선된 드롭다운 설정 (편집 가능한 셀에만)
+ dropdownConfigs.forEach(({ col, options, editableRows }) => {
+ try {
+ console.log(`🎯 Setting dropdown for column ${col}, editable rows: ${editableRows.length}`);
- if (safeOptions.length === 0) return;
+ const safeOptions = options
+ .filter(opt => opt !== null && opt !== undefined && opt !== '')
+ .map(opt => String(opt).trim())
+ .filter(opt => opt.length > 0)
+ .slice(0, 20);
- // 편집 가능한 행에만 드롭다운 적용
- editableRows.forEach(targetRow => {
- try {
- const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
- comboBoxCellType.items(safeOptions);
- comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
+ if (safeOptions.length === 0) return;
- const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(','));
- cellValidator.showInputMessage(false);
- cellValidator.showErrorMessage(false);
+ // 편집 가능한 행에만 드롭다운 적용
+ editableRows.forEach(targetRow => {
+ try {
+ const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox();
+ comboBoxCellType.items(safeOptions);
+ comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text);
- activeSheet.setCellType(targetRow, col, comboBoxCellType);
- activeSheet.setDataValidator(targetRow, col, cellValidator);
-
- // 🚀 편집 권한 명시적 설정
- const cell = activeSheet.getCell(targetRow, col);
- cell.locked(false);
+ const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions.join(','));
+ cellValidator.showInputMessage(false);
+ cellValidator.showErrorMessage(false);
- console.log(`✅ Dropdown applied to editable cell [${targetRow}, ${col}]`);
- } catch (cellError) {
- console.warn(`⚠️ Failed to apply dropdown to [${targetRow}, ${col}]:`, cellError);
- }
- });
- } catch (error) {
- console.error(`❌ Dropdown config failed for column ${col}:`, error);
- }
- });
+ activeSheet.setCellType(targetRow, col, comboBoxCellType);
+ activeSheet.setDataValidator(targetRow, col, cellValidator);
- // 🧊 틀고정 설정
- if (freezeColumnCount > 0) {
- try {
- activeSheet.frozenColumnCount(freezeColumnCount);
- activeSheet.frozenRowCount(1); // 헤더 행도 고정
-
- console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`);
-
- // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항)
- for (let col = 0; col < freezeColumnCount; col++) {
- for (let row = 0; row <= tableData.length; row++) {
- try {
- const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style();
-
- if (row === 0) {
- // 헤더는 기존 스타일 유지
- continue;
- } else {
- // 데이터 셀에 고정 구분선 추가
- if (col === freezeColumnCount - 1) {
- currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium);
- activeSheet.setStyle(row, col, currentStyle);
+ // 🚀 편집 권한 명시적 설정
+ 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);
+ }
+ });
+ } catch (error) {
+ console.error(`❌ Dropdown config failed for column ${col}:`, error);
+ }
+ });
+
+ // 🧊 틀고정 설정
+ if (freezeColumnCount > 0) {
+ try {
+ activeSheet.frozenColumnCount(freezeColumnCount);
+ activeSheet.frozenRowCount(1); // 헤더 행도 고정
+
+ console.log(`🧊 Freeze applied: ${freezeColumnCount} columns, 1 row (header)`);
+
+ // 🎨 고정된 컬럼에 특별한 스타일 추가 (선택사항)
+ for (let col = 0; col < freezeColumnCount; col++) {
+ for (let row = 0; row <= tableData.length; row++) {
+ try {
+ const currentStyle = activeSheet.getStyle(row, col) || new GC.Spread.Sheets.Style();
+
+ if (row === 0) {
+ // 헤더는 기존 스타일 유지
+ continue;
+ } else {
+ // 데이터 셀에 고정 구분선 추가
+ if (col === freezeColumnCount - 1) {
+ currentStyle.borderRight = new GC.Spread.Sheets.LineBorder("#2563eb", GC.Spread.Sheets.LineStyle.medium);
+ activeSheet.setStyle(row, col, currentStyle);
+ }
}
+ } catch (styleError) {
+ console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError);
}
- } catch (styleError) {
- console.warn(`⚠️ Failed to apply freeze border style to [${row}, ${col}]:`, styleError);
}
}
+ } catch (freezeError) {
+ console.error('❌ Failed to apply freeze:', freezeError);
}
- } catch (freezeError) {
- console.error('❌ Failed to apply freeze:', freezeError);
}
- }
- setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData);
-
- console.log(`✅ Optimized GRD_LIST created with freeze:`);
- console.log(` - Total mappings: ${mappings.length}`);
- console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`);
- console.log(` - Dropdown configs: ${dropdownConfigs.length}`);
- console.log(` - Frozen columns: ${freezeColumnCount}`);
-
- return mappings;
-}, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]);
+ setOptimalColumnWidths(activeSheet, visibleColumns, startCol, tableData);
+
+ console.log(`✅ Optimized GRD_LIST created with freeze:`);
+ console.log(` - Total mappings: ${mappings.length}`);
+ console.log(` - Editable cells: ${mappings.filter(m => m.isEditable).length}`);
+ console.log(` - Dropdown configs: ${dropdownConfigs.length}`);
+ console.log(` - Frozen columns: ${freezeColumnCount}`);
+
+ return mappings;
+ }, [tableData, columnsJSON, isFieldEditable, setBatchValues, setBatchStyles, ensureColumnCapacity, ensureRowCapacity, getCellAddress, setOptimalColumnWidths]);
const setupSheetProtectionAndEvents = React.useCallback((activeSheet: any, mappings: CellMapping[]) => {
console.log(`🛡️ Setting up protection and events for ${mappings.length} mappings`);
@@ -817,22 +817,22 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
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);
@@ -840,7 +840,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
// 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,
@@ -854,11 +854,11 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
const textCellType = new GC.Spread.Sheets.CellTypes.Text();
activeSheet.setCellType(cellPos.row, cellPos.col, textCellType);
}
-
+
// 편집 가능 스타일 재적용
const editableStyle = createCellStyle(activeSheet, cellPos.row, cellPos.col, true);
activeSheet.setStyle(cellPos.row, cellPos.col, editableStyle);
-
+
console.log(`🔓 Cell [${cellPos.row}, ${cellPos.col}] ${mapping.attId} set as EDITABLE`);
} else {
// 읽기 전용 셀
@@ -930,7 +930,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
const dataRowIndex = exactMapping.dataRowIndex;
if (dataRowIndex >= 0 && dataRowIndex < tableData.length) {
const rowData = tableData[dataRowIndex];
- if (rowData?.shi === "OUT" || rowData?.shi === null ) {
+ if (rowData?.shi === "OUT" || rowData?.shi === null) {
console.log(`🚫 Row ${dataRowIndex} is in SHI mode`);
toast.warning(`Row ${dataRowIndex + 1}: ${exactMapping.attId} field is read-only (SHI mode)`);
info.cancel = true;
@@ -998,7 +998,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
console.log('🚀 Starting optimized spread initialization...');
setIsInitializing(true);
updateProgress('Initializing...', 0, 100);
-
+
setCurrentSpread(spread);
setHasChanges(false);
setValidationErrors([]);
@@ -1007,7 +1007,7 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
spread.suspendPaint();
spread.suspendEvent();
spread.suspendCalcService();
-
+
updateProgress('Setting up workspace...', 10, 100);
try {
@@ -1021,19 +1021,19 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
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');
-
+
updateProgress('Processing table data...', 50, 100);
mappings = createGrdListTableOptimized(sheet, workingTemplate);
-
+
} else {
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;
@@ -1042,10 +1042,10 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
}
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');
@@ -1058,15 +1058,24 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
const activeSheetName = workingTemplate.SPR_LST_SETUP?.ACT_SHEET;
- const matchingDataSheets = dataSheets.filter(ds =>
- ds.SHEET_NAME === activeSheetName
- );
- if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
- spread.setActiveSheet(activeSheetName);
- }
+ // 🔧 각 DATA_SHEET별로 처리
+ dataSheets.forEach(dataSheet => {
+ const sheetName = dataSheet.SHEET_NAME;
+
+ // 해당 시트가 존재하는지 확인
+ const targetSheet = spread.getSheetFromName(sheetName);
+ if (!targetSheet) {
+ console.warn(`⚠️ Sheet '${sheetName}' not found in template`);
+ return;
+ }
+
+ console.log(`📋 Processing sheet: ${sheetName}`);
+
+ // 해당 시트로 전환
+ spread.setActiveSheet(sheetName);
+ const currentSheet = spread.getActiveSheet();
- matchingDataSheets.forEach(dataSheet => {
if (dataSheet.MAP_CELL_ATT && dataSheet.MAP_CELL_ATT.length > 0) {
dataSheet.MAP_CELL_ATT.forEach((mapping: any) => {
const { ATT_ID, IN } = mapping;
@@ -1076,11 +1085,11 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
if (!cellPos) return;
const requiredRows = cellPos.row + tableData.length;
- if (!ensureRowCapacity(activeSheet, requiredRows)) return;
+ if (!ensureRowCapacity(currentSheet, requiredRows)) return;
// 🚀 배치 데이터 준비
- const valuesToSet: Array<{row: number, col: number, value: any}> = [];
- const stylesToSet: Array<{row: number, col: number, isEditable: boolean}> = [];
+ 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;
@@ -1108,67 +1117,95 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
});
// 🚀 배치 처리
- setBatchValues(activeSheet, valuesToSet);
- setBatchStyles(activeSheet, stylesToSet);
+ setBatchValues(currentSheet, valuesToSet);
+ setBatchStyles(currentSheet, 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);
+ setupOptimizedListValidation(currentSheet, cellPos, columnConfig.options, tableData.length);
}
}
});
}
});
-
+
+ // 🔧 마지막에 activeSheetName으로 다시 전환
+ if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
+ spread.setActiveSheet(activeSheetName);
+ activeSheet = spread.getActiveSheet();
+ }
+
} else if (templateType === 'SPREAD_ITEM' && selectedRow) {
updateProgress('Setting up form fields...', 60, 100);
- const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET;
+ const activeSheetName = workingTemplate.SPR_ITM_LST_SETUP?.ACT_SHEET;
- const matchingDataSheets = dataSheets.filter(ds =>
- ds.SHEET_NAME === activeSheetName
- );
-
- if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
- spread.setActiveSheet(activeSheetName);
+ if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
+ spread.setActiveSheet(activeSheetName);
+ }
+
+ dataSheets.forEach(dataSheet => {
+
+ const sheetName = dataSheet.SHEET_NAME;
+ // 해당 시트가 존재하는지 확인
+ const targetSheet = spread.getSheetFromName(sheetName);
+ if (!targetSheet) {
+ console.warn(`⚠️ Sheet '${sheetName}' not found in template`);
+ return;
}
- matchingDataSheets.forEach(dataSheet => {
+ console.log(`📋 Processing sheet: ${sheetName}`);
+
+ // 해당 시트로 전환
+ spread.setActiveSheet(sheetName);
+ const currentSheet = spread.getActiveSheet();
+
+
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);
+
+ const cell = currentSheet.getCell(cellPos.row, cellPos.col);
cell.value(value ?? null);
-
- const style = createCellStyle(activeSheet, cellPos.row, cellPos.col, isEditable);
- activeSheet.setStyle(cellPos.row, cellPos.col, style);
-
+
+ const style = createCellStyle(currentSheet, cellPos.row, cellPos.col, isEditable);
+ currentSheet.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);
+ setupOptimizedListValidation(currentSheet, cellPos, columnConfig.options, 1);
}
}
});
+
+ // 🔧 마지막에 activeSheetName으로 다시 전환
+ if (activeSheetName && spread.getSheetFromName(activeSheetName)) {
+ spread.setActiveSheet(activeSheetName);
+ activeSheet = spread.getActiveSheet();
+ }
+
+
});
}
}
updateProgress('Configuring interactions...', 90, 100);
setCellMappings(mappings);
-
+
const finalActiveSheet = getSafeActiveSheet(spread, 'setupEvents');
if (finalActiveSheet) {
setupSheetProtectionAndEvents(finalActiveSheet, mappings);
@@ -1201,20 +1238,20 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
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.`);
return;
}
-
+
try {
setIsPending(true);
const activeSheet = currentSpread.getActiveSheet();
-
+
if (templateType === 'SPREAD_ITEM' && selectedRow) {
const dataToSave = { ...selectedRow };
-
+
cellMappings.forEach(mapping => {
if (mapping.isEditable) {
const cellPos = parseCellAddress(mapping.cellAddress);
@@ -1224,59 +1261,59 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
}
}
});
-
+
dataToSave.TAG_NO = selectedRow.TAG_NO;
-
+
const { success, message } = await updateFormDataInDB(
formCode,
contractItemId,
dataToSave
);
-
+
if (!success) {
toast.error(message);
return;
}
-
+
toast.success("Changes saved successfully!");
onUpdateSuccess?.(dataToSave);
-
+
} else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) {
console.log('🔍 Starting batch save process...');
-
+
const updatedRows: GenericData[] = [];
let saveCount = 0;
let checkedCount = 0;
-
+
for (let i = 0; i < tableData.length; i++) {
const originalRow = tableData[i];
const dataToSave = { ...originalRow };
let hasRowChanges = false;
-
+
console.log(`🔍 Processing row ${i} (TAG_NO: ${originalRow.TAG_NO})`);
-
+
cellMappings.forEach(mapping => {
if (mapping.dataRowIndex === i && mapping.isEditable) {
checkedCount++;
-
+
// 🔧 isFieldEditable과 동일한 로직 사용
const rowData = tableData[i];
const fieldEditable = isFieldEditable(mapping.attId, rowData);
-
+
console.log(` 📝 Field ${mapping.attId}: fieldEditable=${fieldEditable}, mapping.isEditable=${mapping.isEditable}`);
-
+
if (fieldEditable) {
const cellPos = parseCellAddress(mapping.cellAddress);
if (cellPos) {
const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
const originalValue = originalRow[mapping.attId];
-
+
// 🔧 개선된 값 비교 (타입 변환 및 null/undefined 처리)
const normalizedCellValue = cellValue === null || cellValue === undefined ? "" : String(cellValue).trim();
const normalizedOriginalValue = originalValue === null || originalValue === undefined ? "" : String(originalValue).trim();
-
+
console.log(` 🔍 ${mapping.attId}: "${normalizedOriginalValue}" -> "${normalizedCellValue}"`);
-
+
if (normalizedCellValue !== normalizedOriginalValue) {
dataToSave[mapping.attId] = cellValue;
hasRowChanges = true;
@@ -1286,18 +1323,18 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
}
}
});
-
+
if (hasRowChanges) {
console.log(`💾 Saving row ${i} with changes`);
dataToSave.TAG_NO = originalRow.TAG_NO;
-
+
try {
const { success, message } = await updateFormDataInDB(
formCode,
contractItemId,
dataToSave
);
-
+
if (success) {
updatedRows.push(dataToSave);
saveCount++;
@@ -1317,9 +1354,9 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
console.log(`ℹ️ No changes in row ${i}`);
}
}
-
+
console.log(`📊 Save summary: ${saveCount} saved, ${checkedCount} fields checked`);
-
+
if (saveCount > 0) {
toast.success(`${saveCount} rows saved successfully!`);
onUpdateSuccess?.(updatedRows);
@@ -1328,10 +1365,10 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
toast.warning("No actual changes were found to save. Please check if the values were properly edited.");
}
}
-
+
setHasChanges(false);
setValidationErrors([]);
-
+
} catch (error) {
console.error("Error saving changes:", error);
toast.error("An unexpected error occurred while saving");
@@ -1339,16 +1376,16 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
setIsPending(false);
}
}, [
- currentSpread,
- hasChanges,
- templateType,
- selectedRow,
- tableData,
- formCode,
- contractItemId,
- onUpdateSuccess,
- cellMappings,
- columnsJSON,
+ currentSpread,
+ hasChanges,
+ templateType,
+ selectedRow,
+ tableData,
+ formCode,
+ contractItemId,
+ onUpdateSuccess,
+ cellMappings,
+ columnsJSON,
validateAllData,
isFieldEditable // 🔧 의존성 추가
]);
@@ -1357,11 +1394,11 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
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-[90vw] max-w-[1400px] h-[85vh] flex flex-col fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-50"
+ className="w-[95vw] max-w-[95vw] h-[90vh] 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>
@@ -1439,13 +1476,13 @@ const createGrdListTableOptimized = React.useCallback((activeSheet: any, templat
<div className="flex-1 overflow-hidden relative">
{/* 🆕 로딩 프로그레스 오버레이 */}
- <LoadingProgress
+ <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/db/schema/rfqLastTBE.ts b/db/schema/rfqLastTBE.ts
index 8800cd3d..e690ce4b 100644
--- a/db/schema/rfqLastTBE.ts
+++ b/db/schema/rfqLastTBE.ts
@@ -34,7 +34,7 @@ export const rfqLastTbeSessions = pgTable(
// 상태 관리
status: varchar("status", { length: 30 })
- .$type<"준비중" | "진행중" | "검토중" | "보류" | "완료" | "취소">()
+ .$type<"생성중"|"준비중" | "진행중" | "검토중" | "보류" | "완료" | "취소">()
.notNull()
.default("준비중"),
diff --git a/db/schema/vendorDocu.ts b/db/schema/vendorDocu.ts
index 3d9ad46c..1c634f64 100644
--- a/db/schema/vendorDocu.ts
+++ b/db/schema/vendorDocu.ts
@@ -1137,7 +1137,7 @@ export const simplifiedDocumentsView = pgView("simplified_documents_view", {
-- projects, vendors 테이블 JOIN (projectId가 이제 documents에 직접 있음)
LEFT JOIN projects p ON d.project_id = p.id AND p.type = 'ship'
LEFT JOIN contracts c ON d.contract_id = c.id
- LEFT JOIN vendors v ON c.vendor_id = v.id
+ LEFT JOIN vendors v ON d.vendor_id = v.id
-- 스테이지 정보 JOIN
LEFT JOIN first_stage_info fsi ON d.id = fsi.document_id
diff --git a/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx b/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx
index 93681c09..31337675 100644
--- a/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx
+++ b/lib/docu-list-rule/document-class/table/document-class-option-add-dialog.tsx
@@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import * as z from "zod"
-import { Plus } from "lucide-react"
+import { Plus, Check, ChevronsUpDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
@@ -25,12 +25,62 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { useParams } from "next/navigation"
import { createDocumentClassOptionItem } from "@/lib/docu-list-rule/document-class/service"
+import { getProjectCode } from "@/lib/projects/service"
+
+// API 응답 타입
+interface ScheduleSetting {
+ COL_NM: string
+ DC_OBX_USE_YN: string
+ PROJ_COL_NM: string
+ PROJ_COL_NM_EN: string
+ SCD_VIEW_MGNT: string
+ USE_YN1: string
+ USE_YN2: string
+}
+
+// 프로젝트 일정 설정을 가져오는 함수
+async function getProjectKindScheduleSetting(projectCode: string): Promise<ScheduleSetting[]> {
+ try {
+ const response = await fetch(
+ `http://60.100.99.217/DDP/Services/VNDRService.svc/GetProjectKindScheduleSetting?PROJ_NO=${projectCode}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+ )
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch schedule settings')
+ }
+
+ const data = await response.json()
+ return data.GetProjectKindScheduleSettingResult || []
+ } catch (error) {
+ console.error('Error fetching schedule settings:', error)
+ return []
+ }
+}
const createOptionSchema = z.object({
- optionCode: z.string().min(1, "코드는 필수입니다."),
+ optionCode: z.string().min(1, "옵션을 선택해주세요."),
})
type CreateOptionSchema = z.infer<typeof createOptionSchema>
@@ -42,7 +92,12 @@ interface DocumentClassOptionAddDialogProps {
export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: DocumentClassOptionAddDialogProps) {
const [open, setOpen] = React.useState(false)
+ const [comboboxOpen, setComboboxOpen] = React.useState(false)
const [isPending, startTransition] = React.useTransition()
+ const [scheduleSettings, setScheduleSettings] = React.useState<ScheduleSetting[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const params = useParams()
+ const projectId = Number(params?.projectId)
const form = useForm<CreateOptionSchema>({
resolver: zodResolver(createOptionSchema),
@@ -51,6 +106,35 @@ export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: Doc
},
})
+ // Dialog가 열릴 때 데이터 로드
+ React.useEffect(() => {
+ if (open && projectId) {
+ loadScheduleSettings()
+ }
+ }, [open, projectId])
+
+ const loadScheduleSettings = async () => {
+ setIsLoading(true)
+ try {
+ // 먼저 projectId로 프로젝트 코드 가져오기
+ const projectCode = await getProjectCode(projectId)
+
+ if (!projectCode) {
+ toast.error("프로젝트 코드를 찾을 수 없습니다.")
+ return
+ }
+
+ // 프로젝트 코드로 일정 설정 가져오기
+ const settings = await getProjectKindScheduleSetting(projectCode)
+ setScheduleSettings(settings)
+ } catch (error) {
+ console.error("Error loading schedule settings:", error)
+ toast.error("옵션 목록을 불러오는 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
const handleSubmit = (data: CreateOptionSchema) => {
startTransition(async () => {
try {
@@ -79,6 +163,10 @@ export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: Doc
form.reset()
}
+ const selectedOption = scheduleSettings.find(
+ (setting) => setting.COL_NM === form.watch("optionCode")
+ )
+
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
@@ -100,11 +188,66 @@ export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: Doc
control={form.control}
name="optionCode"
render={({ field }) => (
- <FormItem>
- <FormLabel>코드</FormLabel>
- <FormControl>
- <Input {...field} placeholder="옵션 코드" />
- </FormControl>
+ <FormItem className="flex flex-col">
+ <FormLabel>옵션 선택</FormLabel>
+ <Popover open={comboboxOpen} onOpenChange={setComboboxOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={comboboxOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ disabled={isLoading}
+ >
+ {isLoading
+ ? "로딩 중..."
+ : selectedOption
+ ? `${selectedOption.COL_NM} - ${selectedOption.PROJ_COL_NM}`
+ : "옵션을 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput placeholder="옵션 검색..." />
+ <CommandEmpty>
+ {isLoading ? "로딩 중..." : "검색 결과가 없습니다."}
+ </CommandEmpty>
+ <CommandGroup className="max-h-[200px] overflow-auto">
+ {scheduleSettings.map((setting) => (
+ <CommandItem
+ key={setting.COL_NM}
+ value={`${setting.COL_NM} ${setting.PROJ_COL_NM}`}
+ onSelect={() => {
+ form.setValue("optionCode", setting.COL_NM)
+ setComboboxOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ field.value === setting.COL_NM
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col">
+ <span className="font-medium">{setting.COL_NM}</span>
+ <span className="text-sm text-muted-foreground">
+ {setting.PROJ_COL_NM}
+ </span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </Command>
+ </PopoverContent>
+ </Popover>
<FormMessage />
</FormItem>
)}
@@ -122,4 +265,4 @@ export function DocumentClassOptionAddDialog({ documentClassId, onSuccess }: Doc
</DialogContent>
</Dialog>
)
-} \ No newline at end of file
+} \ No newline at end of file
diff --git a/lib/evaluation-criteria/stat.ts b/lib/evaluation-criteria/stat.ts
new file mode 100644
index 00000000..c39c8627
--- /dev/null
+++ b/lib/evaluation-criteria/stat.ts
@@ -0,0 +1,413 @@
+"use server"
+
+import db from "@/db/db"
+import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes, projects } from "@/db/schema"
+import { eq, and, inArray } from "drizzle-orm"
+import { getEditableFieldsByTag } from "./services"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+interface VendorFormStatus {
+ vendorId: number
+ vendorName: string
+ formCount: number // 벤더가 가진 form 개수
+ tagCount: number // 벤더가 가진 tag 개수
+ totalFields: number // 입력해야 하는 총 필드 개수
+ completedFields: number // 입력 완료된 필드 개수
+ completionRate: number // 완료율 (%)
+}
+
+export interface FormStatusByVendor {
+ tagCount: number;
+ totalFields: number;
+ completedFields: number;
+ completionRate: number;
+ upcomingCount: number; // 7일 이내 임박한 개수
+ overdueCount: number; // 지연된 개수
+}
+
+export async function getProjectsWithContracts() {
+ try {
+ const projectList = await db
+ .selectDistinct({
+ id: projects.id,
+ projectCode: projects.code,
+ projectName: projects.name,
+ })
+ .from(projects)
+ .innerJoin(contracts, eq(contracts.projectId, projects.id))
+ .orderBy(projects.code)
+
+ return projectList
+ } catch (error) {
+ console.error('Error getting projects with contracts:', error)
+ throw new Error('계약이 있는 프로젝트 조회 중 오류가 발생했습니다.')
+ }
+}
+
+
+
+export async function getVendorFormStatus(projectId?: number): Promise<VendorFormStatus[]> {
+ try {
+ // 1. 벤더 조회 쿼리 수정
+ const vendorList = projectId
+ ? await db
+ .selectDistinct({
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .innerJoin(contracts, eq(contracts.vendorId, vendors.id))
+ .where(eq(contracts.projectId, projectId))
+ : await db
+ .selectDistinct({
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .innerJoin(contracts, eq(contracts.vendorId, vendors.id))
+
+
+ const vendorStatusList: VendorFormStatus[] = []
+
+ for (const vendor of vendorList) {
+ let vendorFormCount = 0
+ let vendorTagCount = 0
+ let vendorTotalFields = 0
+ let vendorCompletedFields = 0
+ const uniqueTags = new Set<string>()
+
+ // 2. 계약 조회 시 projectId 필터 추가
+ const vendorContracts = projectId
+ ? await db
+ .select({
+ id: contracts.id,
+ projectId: contracts.projectId
+ })
+ .from(contracts)
+ .where(
+ and(
+ eq(contracts.vendorId, vendor.vendorId),
+ eq(contracts.projectId, projectId)
+ )
+ )
+ : await db
+ .select({
+ id: contracts.id,
+ projectId: contracts.projectId
+ })
+ .from(contracts)
+ .where(eq(contracts.vendorId, vendor.vendorId))
+
+
+ for (const contract of vendorContracts) {
+ // 3. 계약별 contractItems 조회
+ const contractItemsList = await db
+ .select({
+ id: contractItems.id
+ })
+ .from(contractItems)
+ .where(eq(contractItems.contractId, contract.id))
+
+ for (const contractItem of contractItemsList) {
+ // 4. contractItem별 forms 조회
+ const formsList = await db
+ .select({
+ id: forms.id,
+ formCode: forms.formCode,
+ contractItemId: forms.contractItemId
+ })
+ .from(forms)
+ .where(eq(forms.contractItemId, contractItem.id))
+
+ vendorFormCount += formsList.length
+
+ // 5. formEntries 조회
+ const entriesList = await db
+ .select({
+ id: formEntries.id,
+ formCode: formEntries.formCode,
+ data: formEntries.data
+ })
+ .from(formEntries)
+ .where(eq(formEntries.contractItemId, contractItem.id))
+
+ // 6. TAG별 편집 가능 필드 조회
+ const editableFieldsByTag = await getEditableFieldsByTag(contractItem.id, contract.projectId)
+
+ for (const entry of entriesList) {
+ // formMetas에서 해당 formCode의 columns 조회
+ const metaResult = await db
+ .select({
+ columns: formMetas.columns
+ })
+ .from(formMetas)
+ .where(
+ and(
+ eq(formMetas.formCode, entry.formCode),
+ eq(formMetas.projectId, contract.projectId)
+ )
+ )
+ .limit(1)
+
+ if (metaResult.length === 0) continue
+
+ const metaColumns = metaResult[0].columns as any[]
+
+ // shi가 'IN' 또는 'BOTH'인 필드 찾기
+ const inputRequiredFields = metaColumns
+ .filter(col => col.shi === 'IN' || col.shi === 'BOTH')
+ .map(col => col.key)
+
+ // entry.data 분석 (배열로 가정)
+ const dataArray = Array.isArray(entry.data) ? entry.data : []
+
+ for (const dataItem of dataArray) {
+ if (typeof dataItem !== 'object' || !dataItem) continue
+
+ const tagNo = dataItem.TAG_NO
+ if (tagNo) {
+ uniqueTags.add(tagNo)
+
+ // TAG별 편집 가능 필드 가져오기
+ const tagEditableFields = editableFieldsByTag.get(tagNo) || []
+
+ // 최종 입력 필요 필드 = shi 기반 필드 + TAG 기반 편집 가능 필드
+ const allRequiredFields = inputRequiredFields.filter(field =>
+ tagEditableFields.includes(field)
+ )
+ // 각 필드별 입력 상태 체크
+ for (const fieldKey of allRequiredFields) {
+ vendorTotalFields++
+
+ const fieldValue = dataItem[fieldKey]
+ // 값이 있고, 빈 문자열이 아니고, null이 아니면 입력 완료
+ if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
+ vendorCompletedFields++
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 완료율 계산
+ const completionRate = vendorTotalFields > 0
+ ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10
+ : 0
+
+ vendorStatusList.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName || '이름 없음',
+ formCount: vendorFormCount,
+ tagCount: uniqueTags.size,
+ totalFields: vendorTotalFields,
+ completedFields: vendorCompletedFields,
+ completionRate
+ })
+ }
+
+ return vendorStatusList
+
+ } catch (error) {
+ console.error('Error getting vendor form status:', error)
+ throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.')
+ }
+}
+
+
+
+export async function getFormStatusByVendor(projectId: number, formCode: string): Promise<FormStatusByVendor[]> {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const vendorStatusList: FormStatusByVendor[] = []
+ const vendorId = Number(session.user.companyId)
+
+ const vendorContracts = await db
+ .select({
+ id: contracts.id,
+ projectId: contracts.projectId
+ })
+ .from(contracts)
+ .where(
+ and(
+ eq(contracts.vendorId, vendorId),
+ eq(contracts.projectId, projectId)
+ )
+ )
+
+ const contractIds = vendorContracts.map(v => v.id)
+
+ const contractItemsList = await db
+ .select({
+ id: contractItems.id
+ })
+ .from(contractItems)
+ .where(inArray(contractItems.contractId, contractIds))
+
+ const contractItemIds = contractItemsList.map(v => v.id)
+
+ let vendorFormCount = 0
+ let vendorTagCount = 0
+ let vendorTotalFields = 0
+ let vendorCompletedFields = 0
+ let vendorUpcomingCount = 0 // 7일 이내 임박한 개수
+ let vendorOverdueCount = 0 // 지연된 개수
+ const uniqueTags = new Set<string>()
+ const processedTags = new Set<string>() // 중복 처리 방지용
+
+ // 현재 날짜와 7일 후 날짜 계산
+ const today = new Date()
+ today.setHours(0, 0, 0, 0) // 시간 부분 제거
+ const sevenDaysLater = new Date(today)
+ sevenDaysLater.setDate(sevenDaysLater.getDate() + 7)
+
+ // 4. contractItem별 forms 조회
+ const formsList = await db
+ .select({
+ id: forms.id,
+ formCode: forms.formCode,
+ contractItemId: forms.contractItemId
+ })
+ .from(forms)
+ .where(
+ and(
+ inArray(forms.contractItemId, contractItemIds),
+ eq(forms.formCode, formCode)
+ )
+ )
+
+ vendorFormCount += formsList.length
+
+ // 5. formEntries 조회
+ const entriesList = await db
+ .select({
+ id: formEntries.id,
+ formCode: formEntries.formCode,
+ data: formEntries.data
+ })
+ .from(formEntries)
+ .where(
+ and(
+ inArray(formEntries.contractItemId, contractItemIds),
+ eq(formEntries.formCode, formCode)
+ )
+ )
+
+ // 6. TAG별 편집 가능 필드 조회
+ const editableFieldsByTag = new Map<string, string[]>()
+
+ for (const contractItemId of contractItemIds) {
+ const tagFields = await getEditableFieldsByTag(contractItemId, projectId)
+
+ tagFields.forEach((fields, tagNo) => {
+ if (!editableFieldsByTag.has(tagNo)) {
+ editableFieldsByTag.set(tagNo, fields)
+ } else {
+ const existingFields = editableFieldsByTag.get(tagNo) || []
+ const mergedFields = [...new Set([...existingFields, ...fields])]
+ editableFieldsByTag.set(tagNo, mergedFields)
+ }
+ })
+ }
+
+ for (const entry of entriesList) {
+ const metaResult = await db
+ .select({
+ columns: formMetas.columns
+ })
+ .from(formMetas)
+ .where(
+ and(
+ eq(formMetas.formCode, entry.formCode),
+ eq(formMetas.projectId, projectId)
+ )
+ )
+ .limit(1)
+
+ if (metaResult.length === 0) continue
+
+ const metaColumns = metaResult[0].columns as any[]
+
+ const inputRequiredFields = metaColumns
+ .filter(col => col.shi === 'IN' || col.shi === 'BOTH')
+ .map(col => col.key)
+
+ const dataArray = Array.isArray(entry.data) ? entry.data : []
+
+ for (const dataItem of dataArray) {
+ if (typeof dataItem !== 'object' || !dataItem) continue
+
+ const tagNo = dataItem.TAG_NO
+ if (tagNo) {
+ uniqueTags.add(tagNo)
+
+ // TAG별 편집 가능 필드 가져오기
+ const tagEditableFields = editableFieldsByTag.get(tagNo) || []
+
+ const allRequiredFields = inputRequiredFields.filter(field =>
+ tagEditableFields.includes(field)
+ )
+
+ // 해당 TAG의 필드 완료 상태 체크
+ let tagHasIncompleteFields = false
+
+ for (const fieldKey of allRequiredFields) {
+ vendorTotalFields++
+
+ const fieldValue = dataItem[fieldKey]
+ if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
+ vendorCompletedFields++
+ } else {
+ tagHasIncompleteFields = true
+ }
+ }
+
+ // 미완료 TAG에 대해서만 날짜 체크 (TAG당 한 번만 처리)
+ if (!processedTags.has(tagNo) && tagHasIncompleteFields) {
+ processedTags.add(tagNo)
+
+ const targetDate = dataItem.target_date
+ if (targetDate) {
+ const target = new Date(targetDate)
+ target.setHours(0, 0, 0, 0) // 시간 부분 제거
+
+ if (target < today) {
+ // 미완료이면서 지연된 경우 (오늘보다 이전)
+ vendorOverdueCount++
+ } else if (target >= today && target <= sevenDaysLater) {
+ // 미완료이면서 7일 이내 임박한 경우
+ vendorUpcomingCount++
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 완료율 계산
+ const completionRate = vendorTotalFields > 0
+ ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10
+ : 0
+
+ vendorStatusList.push({
+ tagCount: uniqueTags.size,
+ totalFields: vendorTotalFields,
+ completedFields: vendorCompletedFields,
+ completionRate,
+ upcomingCount: vendorUpcomingCount,
+ overdueCount: vendorOverdueCount
+ })
+
+ return vendorStatusList
+
+ } catch (error) {
+ console.error('Error getting vendor form status:', error)
+ throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.')
+ }
+} \ No newline at end of file
diff --git a/lib/forms/stat.ts b/lib/forms/stat.ts
index 80193c48..054f2462 100644
--- a/lib/forms/stat.ts
+++ b/lib/forms/stat.ts
@@ -2,8 +2,10 @@
import db from "@/db/db"
import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes, projects } from "@/db/schema"
-import { eq, and } from "drizzle-orm"
+import { eq, and, inArray } from "drizzle-orm"
import { getEditableFieldsByTag } from "./services"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
interface VendorFormStatus {
vendorId: number
@@ -15,6 +17,15 @@ interface VendorFormStatus {
completionRate: number // 완료율 (%)
}
+export interface FormStatusByVendor {
+ tagCount: number;
+ totalFields: number;
+ completedFields: number;
+ completionRate: number;
+ upcomingCount: number; // 7일 이내 임박한 개수
+ overdueCount: number; // 지연된 개수
+}
+
export async function getProjectsWithContracts() {
try {
const projectList = await db
@@ -204,3 +215,199 @@ export async function getVendorFormStatus(projectId?: number): Promise<VendorFor
throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.')
}
}
+
+
+
+export async function getFormStatusByVendor(projectId: number, formCode: string): Promise<FormStatusByVendor[]> {
+ try {
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const vendorStatusList: FormStatusByVendor[] = []
+ const vendorId = Number(session.user.companyId)
+
+ const vendorContracts = await db
+ .select({
+ id: contracts.id,
+ projectId: contracts.projectId
+ })
+ .from(contracts)
+ .where(
+ and(
+ eq(contracts.vendorId, vendorId),
+ eq(contracts.projectId, projectId)
+ )
+ )
+
+ const contractIds = vendorContracts.map(v => v.id)
+
+ const contractItemsList = await db
+ .select({
+ id: contractItems.id
+ })
+ .from(contractItems)
+ .where(inArray(contractItems.contractId, contractIds))
+
+ const contractItemIds = contractItemsList.map(v => v.id)
+
+ let vendorFormCount = 0
+ let vendorTagCount = 0
+ let vendorTotalFields = 0
+ let vendorCompletedFields = 0
+ let vendorUpcomingCount = 0 // 7일 이내 임박한 개수
+ let vendorOverdueCount = 0 // 지연된 개수
+ const uniqueTags = new Set<string>()
+ const processedTags = new Set<string>() // 중복 처리 방지용
+
+ // 현재 날짜와 7일 후 날짜 계산
+ const today = new Date()
+ today.setHours(0, 0, 0, 0) // 시간 부분 제거
+ const sevenDaysLater = new Date(today)
+ sevenDaysLater.setDate(sevenDaysLater.getDate() + 7)
+
+ // 4. contractItem별 forms 조회
+ const formsList = await db
+ .select({
+ id: forms.id,
+ formCode: forms.formCode,
+ contractItemId: forms.contractItemId
+ })
+ .from(forms)
+ .where(
+ and(
+ inArray(forms.contractItemId, contractItemIds),
+ eq(forms.formCode, formCode)
+ )
+ )
+
+ vendorFormCount += formsList.length
+
+ // 5. formEntries 조회
+ const entriesList = await db
+ .select({
+ id: formEntries.id,
+ formCode: formEntries.formCode,
+ data: formEntries.data
+ })
+ .from(formEntries)
+ .where(
+ and(
+ inArray(formEntries.contractItemId, contractItemIds),
+ eq(formEntries.formCode, formCode)
+ )
+ )
+
+ // 6. TAG별 편집 가능 필드 조회
+ const editableFieldsByTag = new Map<string, string[]>()
+
+ for (const contractItemId of contractItemIds) {
+ const tagFields = await getEditableFieldsByTag(contractItemId, projectId)
+
+ tagFields.forEach((fields, tagNo) => {
+ if (!editableFieldsByTag.has(tagNo)) {
+ editableFieldsByTag.set(tagNo, fields)
+ } else {
+ const existingFields = editableFieldsByTag.get(tagNo) || []
+ const mergedFields = [...new Set([...existingFields, ...fields])]
+ editableFieldsByTag.set(tagNo, mergedFields)
+ }
+ })
+ }
+
+ for (const entry of entriesList) {
+ const metaResult = await db
+ .select({
+ columns: formMetas.columns
+ })
+ .from(formMetas)
+ .where(
+ and(
+ eq(formMetas.formCode, entry.formCode),
+ eq(formMetas.projectId, projectId)
+ )
+ )
+ .limit(1)
+
+ if (metaResult.length === 0) continue
+
+ const metaColumns = metaResult[0].columns as any[]
+
+ const inputRequiredFields = metaColumns
+ .filter(col => col.shi === 'IN' || col.shi === 'BOTH')
+ .map(col => col.key)
+
+ const dataArray = Array.isArray(entry.data) ? entry.data : []
+
+ for (const dataItem of dataArray) {
+ if (typeof dataItem !== 'object' || !dataItem) continue
+
+ const tagNo = dataItem.TAG_NO
+ if (tagNo) {
+ uniqueTags.add(tagNo)
+
+ // TAG별 편집 가능 필드 가져오기
+ const tagEditableFields = editableFieldsByTag.get(tagNo) || []
+
+ const allRequiredFields = inputRequiredFields.filter(field =>
+ tagEditableFields.includes(field)
+ )
+
+ // 해당 TAG의 필드 완료 상태 체크
+ let tagHasIncompleteFields = false
+
+ for (const fieldKey of allRequiredFields) {
+ vendorTotalFields++
+
+ const fieldValue = dataItem[fieldKey]
+ if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') {
+ vendorCompletedFields++
+ } else {
+ tagHasIncompleteFields = true
+ }
+ }
+
+ // 미완료 TAG에 대해서만 날짜 체크 (TAG당 한 번만 처리)
+ if (!processedTags.has(tagNo) && tagHasIncompleteFields) {
+ processedTags.add(tagNo)
+
+ const targetDate = dataItem.DUE_DATE
+ if (targetDate) {
+ const target = new Date(targetDate)
+ target.setHours(0, 0, 0, 0) // 시간 부분 제거
+
+ if (target < today) {
+ // 미완료이면서 지연된 경우 (오늘보다 이전)
+ vendorOverdueCount++
+ } else if (target >= today && target <= sevenDaysLater) {
+ // 미완료이면서 7일 이내 임박한 경우
+ vendorUpcomingCount++
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 완료율 계산
+ const completionRate = vendorTotalFields > 0
+ ? Math.round((vendorCompletedFields / vendorTotalFields) * 100 * 10) / 10
+ : 0
+
+ vendorStatusList.push({
+ tagCount: uniqueTags.size,
+ totalFields: vendorTotalFields,
+ completedFields: vendorCompletedFields,
+ completionRate,
+ upcomingCount: vendorUpcomingCount,
+ overdueCount: vendorOverdueCount
+ })
+
+ return vendorStatusList
+
+ } catch (error) {
+ console.error('Error getting vendor form status:', error)
+ throw new Error('벤더별 Form 입력 현황 조회 중 오류가 발생했습니다.')
+ }
+} \ No newline at end of file
diff --git a/lib/mail/templates/pq.hbs b/lib/mail/templates/pq.hbs
index 02523696..abdff056 100644
--- a/lib/mail/templates/pq.hbs
+++ b/lib/mail/templates/pq.hbs
@@ -61,9 +61,6 @@
아래의 해당 링크를 통해 당사 eVCP시스템에 접속하시어 요청드린 PQ 항목 및 자료에 대한 제출 요청드립니다.
</p>
- <p style="font-size:16px; line-height:32px;">
- 별도의 견적을 제출하시어 당사에서 적극 검토할 수 있도록 협조 바랍니다.
- </p>
<p style="font-size:16px; line-height:32px;">
귀사의 제출 자료 및 정보는 아래의 제출 마감일 이전에 당사로 제출 되어야 하며,
diff --git a/lib/mail/templates/vendor-rejected.hbs b/lib/mail/templates/vendor-rejected.hbs
new file mode 100644
index 00000000..22a1d6f3
--- /dev/null
+++ b/lib/mail/templates/vendor-rejected.hbs
@@ -0,0 +1,195 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>eVCP 메일</title>
+ <style>
+ body {
+ margin: 0 !important;
+ padding: 20px !important;
+ background-color: #f4f4f4;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ }
+ .email-container {
+ max-width: 600px;
+ margin: 0 auto;
+ background-color: #ffffff;
+ padding: 20px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ }
+ .rejected-badge {
+ display: inline-block;
+ background-color: #ef4444;
+ color: white;
+ padding: 4px 12px;
+ border-radius: 16px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ margin-bottom: 16px;
+ }
+ .cta-button {
+ display: inline-block;
+ width: 250px;
+ padding: 12px 20px;
+ background-color: #163CC4;
+ color: #ffffff !important;
+ text-decoration: none;
+ border-radius: 8px;
+ text-align: center;
+ line-height: 28px;
+ margin: 8px 0;
+ }
+ .highlight-box {
+ background-color: #fef2f2;
+ border-left: 4px solid #ef4444;
+ padding: 16px;
+ margin: 16px 0;
+ border-radius: 0 8px 8px 0;
+ }
+ </style>
+</head>
+<body>
+ <div class="email-container">
+ <!-- Header -->
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;">
+ <tr>
+ <td align="center">
+ <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
+ </td>
+ </tr>
+ </table>
+
+ <!-- Rejected Badge -->
+ <div class="rejected-badge">
+ {{#if (eq language 'ko')}}등록 거절{{else}}REGISTRATION REJECTED{{/if}}
+ </div>
+
+ <!-- Title -->
+ <h1 style="font-size:28px; margin-bottom:16px; color:#111827;">
+ {{#if (eq language 'ko')}}
+ 업체 등록 신청이 거절되었습니다
+ {{else}}
+ Your Vendor Registration Has Been Rejected
+ {{/if}}
+ </h1>
+
+ <!-- Greeting -->
+ <p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ {{#if (eq language 'ko')}}
+ 안녕하세요, <strong>{{vendorName}}</strong> 담당자님.
+ {{else}}
+ Hello, <strong>{{vendorName}}</strong> representative.
+ {{/if}}
+ </p>
+
+ <!-- Main Content -->
+ <p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ {{#if (eq language 'ko')}}
+ 귀하의 업체 등록 신청이 검토 결과 거절되었습니다.
+ 등록 기준에 부합하지 않거나 추가 정보 확인이 필요하여 거절 처리되었습니다.
+ {{else}}
+ After review, your vendor registration application has been rejected.
+ The application did not meet our registration criteria or required additional verification.
+ {{/if}}
+ </p>
+
+ <!-- Highlight Box -->
+ <div class="highlight-box">
+ <h3 style="margin-top:0; margin-bottom:12px; color:#dc2626;">
+ {{#if (eq language 'ko')}}거절 사유 및 향후 절차{{else}}Rejection Reason & Next Steps{{/if}}
+ </h3>
+ <ol style="margin:0; padding-left:20px;">
+ <li style="margin-bottom:8px;">
+ {{#if (eq language 'ko')}}
+ 등록 기준에 부합하지 않거나 제출 정보가 불충분합니다
+ {{else}}
+ The application did not meet registration criteria or submitted information was insufficient
+ {{/if}}
+ </li>
+ <li style="margin-bottom:8px;">
+ {{#if (eq language 'ko')}}
+ 추가 정보 확인 및 보완이 필요한 경우 재신청 가능합니다
+ {{else}}
+ You may reapply if you can provide additional information and meet requirements
+ {{/if}}
+ </li>
+ <li style="margin-bottom:8px;">
+ {{#if (eq language 'ko')}}
+ 재신청을 원하시면 모든 필요 서류를 준비하여 다시 신청해주세요
+ {{else}}
+ If you wish to reapply, please prepare all required documents and submit again
+ {{/if}}
+ </li>
+ </ol>
+ </div>
+
+ <!-- Action Button -->
+ <div style="text-align: center; margin: 24px 0;">
+ <a href="{{loginUrl}}" target="_blank" class="cta-button">
+ {{#if (eq language 'ko')}}
+ eVCP 플랫폼 방문하기
+ {{else}}
+ Visit eVCP Platform
+ {{/if}}
+ </a>
+ </div>
+
+ <!-- Account Info -->
+ <div style="background-color:#f9fafb; padding:16px; border-radius:8px; margin:16px 0;">
+ <h4 style="margin-top:0; margin-bottom:12px; color:#374151;">
+ {{#if (eq language 'ko')}}신청 정보{{else}}Application Information{{/if}}
+ </h4>
+ <p style="margin:4px 0; font-size:14px; color:#6b7280;">
+ <strong>{{#if (eq language 'ko')}}업체명{{else}}Company{{/if}}:</strong> {{vendorName}}
+ </p>
+ <p style="margin:4px 0; font-size:14px; color:#6b7280;">
+ <strong>{{#if (eq language 'ko')}}이메일{{else}}Email{{/if}}:</strong> {{email}}
+ </p>
+ <p style="margin:4px 0; font-size:14px; color:#6b7280;">
+ <strong>{{#if (eq language 'ko')}}신청 상태{{else}}Application Status{{/if}}:</strong>
+ <span style="color:#ef4444; font-weight:600;">
+ {{#if (eq language 'ko')}}거절됨{{else}}Rejected{{/if}}
+ </span>
+ </p>
+ </div>
+
+ <!-- Support Message -->
+ <p style="font-size:16px; line-height:24px; margin-top:24px; color:#6b7280;">
+ {{#if (eq language 'ko')}}
+ 등록 기준에 대한 자세한 내용은 eVCP 플랫폼을 방문하시거나
+ <a href="mailto:{{supportEmail}}" style="color:#163CC4;">{{supportEmail}}</a>로
+ 문의해 주세요.
+ {{else}}
+ For more information about registration criteria, please visit the eVCP platform or
+ contact us at <a href="mailto:{{supportEmail}}" style="color:#163CC4;">{{supportEmail}}</a>.
+ {{/if}}
+ </p>
+
+ <!-- Footer -->
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
+ <tr>
+ <td align="center">
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">
+ © {{currentYear}} EVCP.
+ {{#if (eq language 'ko')}}
+ 모든 권리 보유.
+ {{else}}
+ All rights reserved.
+ {{/if}}
+ </p>
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">
+ {{#if (eq language 'ko')}}
+ 본 이메일은 발신 전용입니다. 회신하지 마세요.
+ {{else}}
+ This is an automated email. Please do not reply.
+ {{/if}}
+ </p>
+ </td>
+ </tr>
+ </table>
+ </div>
+</body>
+</html>
diff --git a/lib/projects/service.ts b/lib/projects/service.ts
index 3f562e20..4685fce4 100644
--- a/lib/projects/service.ts
+++ b/lib/projects/service.ts
@@ -112,4 +112,27 @@ export async function getAllProjectInfoByProjectCode(projectCode: string) {
.from(projects)
.where(eq(projects.code, projectCode))
.limit(1);
+}
+
+/**
+ * projectId로 프로젝트 코드를 가져오는 함수
+ * @param projectId - 프로젝트 ID
+ * @returns 프로젝트 코드 또는 null
+ */
+export async function getProjectCode(projectId: number): Promise<string | null> {
+ try {
+ const project = await db.project.findUnique({
+ where: {
+ id: projectId,
+ },
+ select: {
+ code: true,
+ },
+ })
+
+ return project?.code || null
+ } catch (error) {
+ console.error("Error fetching project code:", error)
+ return null
+ }
} \ No newline at end of file
diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx
index 8be5210f..076fb153 100644
--- a/lib/rfq-last/attachment/vendor-response-table.tsx
+++ b/lib/rfq-last/attachment/vendor-response-table.tsx
@@ -159,7 +159,6 @@ export function VendorResponseTable({
const [isUpdating, setIsUpdating] = React.useState(false);
const [showTypeDialog, setShowTypeDialog] = React.useState(false);
const [selectedType, setSelectedType] = React.useState<"구매" | "설계" | "">("");
- console.log(data,"data")
const [selectedVendor, setSelectedVendor] = React.useState<string | null>(null);
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 8eed9bee..09d707d7 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -3643,7 +3643,7 @@ async function handleTbeSession({
sessionCode: sessionCode,
sessionTitle: `${rfqData.rfqCode} - ${vendor.vendorName} 기술검토`,
sessionType: "initial",
- status: "준비중",
+ status: "생성중",
evaluationResult: null,
plannedStartDate: rfqData.dueDate
? addDays(new Date(rfqData.dueDate), 1)
@@ -4738,13 +4738,12 @@ export async function updateShortList(
// 트랜잭션으로 처리
const result = await db.transaction(async (tx) => {
- // 해당 RFQ의 모든 벤더들의 shortList를 먼저 false로 설정 (선택적)
- // 만약 선택된 것만 true로 하고 나머지는 그대로 두려면 이 부분 제거
+ // 1. 해당 RFQ의 모든 벤더들의 shortList를 먼저 false로 설정
await tx
.update(rfqLastDetails)
.set({
shortList: false,
- updatedBy: session.user.id,
+ updatedBy: Number(session.user.id),
updatedAt: new Date()
})
.where(
@@ -4754,15 +4753,16 @@ export async function updateShortList(
)
);
- // 선택된 벤더들의 shortList를 true로 설정
+ // 2. 선택된 벤더들 처리
if (vendorIds.length > 0) {
- const updates = await Promise.all(
+ // 2-1. 선택된 벤더들의 shortList를 true로 설정
+ const updatedDetails = await Promise.all(
vendorIds.map(vendorId =>
tx
.update(rfqLastDetails)
.set({
shortList: shortListStatus,
- updatedBy: session.user.id,
+ updatedBy: Number(session.user.id),
updatedAt: new Date()
})
.where(
@@ -4776,17 +4776,84 @@ export async function updateShortList(
)
);
+ // 2-2. TBE 세션 처리 (shortList가 true인 경우에만)
+ if (shortListStatus) {
+ // 각 벤더에 대한 rfqLastDetailsId 추출
+ const detailsMap = new Map(
+ updatedDetails.flat().map(detail => [detail.vendorsId, detail.id])
+ );
+
+ // TBE 세션 생성 또는 업데이트
+ await Promise.all(
+ vendorIds.map(async (vendorId) => {
+ const rfqLastDetailsId = detailsMap.get(vendorId);
+
+ if (!rfqLastDetailsId) {
+ console.warn(`rfqLastDetailsId not found for vendorId: ${vendorId}`);
+ return;
+ }
+
+ // 기존 활성 TBE 세션이 있는지 확인
+ const existingSession = await tx
+ .select()
+ .from(rfqLastTbeSessions)
+ .where(
+ and(
+ eq(rfqLastTbeSessions.rfqsLastId, rfqId),
+ eq(rfqLastTbeSessions.vendorId, vendorId),
+ inArray(rfqLastTbeSessions.status, ["생성중", "준비중", "진행중", "검토중", "보류"])
+ )
+ )
+ .limit(1);
+
+ if (existingSession.length > 0) {
+ // 기존 세션이 있으면 상태 업데이트
+ await tx
+ .update(rfqLastTbeSessions)
+ .set({
+ status: "준비중",
+ updatedBy: session.user.id,
+ updatedAt: new Date()
+ })
+ .where(eq(rfqLastTbeSessions.id, existingSession[0].id));
+ }
+ })
+ );
+ } else {
+ // shortList가 false인 경우, 해당 벤더들의 활성 TBE 세션을 취소 상태로 변경
+ await Promise.all(
+ vendorIds.map(vendorId =>
+ tx
+ .update(rfqLastTbeSessions)
+ .set({
+ status: "취소",
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastTbeSessions.rfqsLastId, rfqId),
+ eq(rfqLastTbeSessions.vendorId, vendorId),
+ inArray(rfqLastTbeSessions.status, ["생성중", "준비중", "진행중", "검토중", "보류"])
+ )
+ )
+ )
+ );
+ }
+
return {
success: true,
- updatedCount: updates.length,
- vendorIds
+ updatedCount: updatedDetails.length,
+ vendorIds,
+ tbeSessionsUpdated: shortListStatus
};
}
return {
success: true,
updatedCount: 0,
- vendorIds: []
+ vendorIds: [],
+ tbeSessionsUpdated: false
};
});
diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts
index 5615db7a..6a5816d4 100644
--- a/lib/rfq-last/validations.ts
+++ b/lib/rfq-last/validations.ts
@@ -56,7 +56,7 @@ import { RfqLastAttachments } from "@/db/schema";
search: parseAsString.withDefault(""),
// RFQ 카테고리 (전체/일반견적/ITB/RFQ)
- rfqCategory: parseAsStringEnum(["all", "general", "itb", "rfq"]).withDefault("all"),
+ rfqCategory: parseAsStringEnum(["all", "general", "itb", "rfq"]),
});
// ============= 타입 정의 =============
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 89a42602..17433773 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -360,7 +360,7 @@ export function RfqVendorTable({
// 선택된 벤더 ID들 추출
const selectedVendorIds = rfqCode?.startsWith("I") ? selectedRows
- .filter(v => v.shortList)
+ // .filter(v => v.shortList)
.map(row => row.vendorId)
.filter(id => id != null) :
selectedRows
@@ -1218,7 +1218,7 @@ export function RfqVendorTable({
},
size: 80,
},
- ...(rfqCode?.startsWith("I") ? [{
+ ...(!rfqCode?.startsWith("F") ? [{
accessorKey: "shortList",
filterFn: createFilterFn("boolean"), // boolean으로 변경
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />,
@@ -1482,7 +1482,7 @@ export function RfqVendorTable({
label: "스페어파트",
type: "boolean"
},
- ...(rfqCode?.startsWith("I") ? [{
+ ...(!rfqCode?.startsWith("I") ? [{
id: "shortList",
label: "Short List",
type: "select",
@@ -1577,7 +1577,7 @@ export function RfqVendorTable({
</Button>
{/* Short List 확정 버튼 */}
- {rfqCode?.startsWith("I") &&
+ {!rfqCode?.startsWith("F") &&
<Button
variant="outline"
size="sm"
diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts
index 310ef486..b81762c6 100644
--- a/lib/sedp/get-form-tags.ts
+++ b/lib/sedp/get-form-tags.ts
@@ -44,6 +44,96 @@ interface Column {
shi?: string | null;
}
+interface newRegister {
+ PROJ_NO: string;
+ MAP_ID: string;
+ EP_ID: string;
+ CATEGORY: string;
+ BYPASS: boolean;
+ REG_TYPE_ID: string;
+ TOOL_ID: string;
+ TOOL_TYPE: string;
+ SCOPES: string[];
+ MAP_CLS: {
+ TOOL_ATT_NAME: string;
+ ITEMS: ClassItmes[];
+ };
+ MAP_ATT: MapAttribute[];
+ MAP_TMPLS: string[];
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string;
+ _id: string;
+}
+
+interface ClassItmes {
+ SEDP_OBJ_CLS_ID: string;
+ TOOL_VALS: string;
+ ISDEFALUT: boolean;
+}
+
+interface MapAttribute {
+ SEDP_ATT_ID: string;
+ TOOL_ATT_NAME: string;
+ KEY_YN: boolean;
+ DUE_DATE: string; //"YYYY-MM-DDTHH:mm:ssZ"
+ INOUT: string | null;
+}
+
+
+
+async function getNewRegisters(projectCode: string): Promise<newRegister[]> {
+ try {
+ // 토큰(API 키) 가져오기
+ const apiKey = await getSEDPToken();
+
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ "TOOL_ID": "eVCP"
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`새 레지스터 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ // 안전하게 JSON 파싱
+ let data;
+ try {
+ data = await response.json();
+ } catch (parseError) {
+ console.error(`프로젝트 ${projectCode}의 새 레지스터 응답 파싱 실패:`, parseError);
+ // 응답 내용 로깅
+ const text = await response.clone().text();
+ console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ throw new Error(`새 레지스터 응답 파싱 실패: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
+ }
+
+ // 결과를 배열로 변환 (단일 객체인 경우 배열로 래핑)
+ let registers: newRegister[] = Array.isArray(data) ? data : [data];
+
+ console.log(`프로젝트 ${projectCode}에서 ${registers.length}개의 새 레지스터를 가져왔습니다.`);
+ return registers;
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 새 레지스터 가져오기 실패:`, error);
+ throw error;
+ }
+}
+
+
/**
* 태그 가져오기 서비스 함수
* contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장
@@ -70,6 +160,10 @@ export async function importTagsFromSEDP(
// SEDP API에서 태그 데이터 가져오기
const tagData = await fetchTagDataFromSEDP(projectCode, formCode);
+ const newRegisters = await getNewRegisters(projectCode);
+
+ const registerMatched = newRegisters.find(v => v.REG_TYPE_ID === formCode).MAP_ATT
+
// 트랜잭션으로 모든 DB 작업 처리
return await db.transaction(async (tx) => {
@@ -459,7 +553,7 @@ export async function importTagsFromSEDP(
}
}
- const packageCode = projectType === "ship" ? tagEntry.ATTRIBUTES.find(v=>v.ATT_ID === "CM3003")?.VALUE :tagEntry.ATTRIBUTES.find(v=>v.ATT_ID === "ME5074")?.VALUE
+ const packageCode = projectType === "ship" ? tagEntry.ATTRIBUTES.find(v => v.ATT_ID === "CM3003")?.VALUE : tagEntry.ATTRIBUTES.find(v => v.ATT_ID === "ME5074")?.VALUE
// 기본 태그 데이터 객체 생성 (formEntries용)
const tagObject: any = {
@@ -470,9 +564,11 @@ export async function importTagsFromSEDP(
VNDRCD: vendorRecord[0].vendorCode,
VNDRNM_1: vendorRecord[0].vendorName,
status: "From S-EDP", // SEDP에서 가져온 데이터임을 표시
- ...(projectType === "ship" ? { CM3003: packageCode } : { ME5074:packageCode })
+ ...(projectType === "ship" ? { CM3003: packageCode } : { ME5074: packageCode })
}
+ let latestDueDate: Date | null = null;
+
// tags 테이블용 데이터 (UPSERT용)
const tagRecord = {
contractItemId: packageId,
@@ -491,7 +587,7 @@ export async function importTagsFromSEDP(
if (Array.isArray(tagEntry.ATTRIBUTES)) {
for (const attr of tagEntry.ATTRIBUTES) {
const columnInfo = columnsJSON.find(col => col.key === attr.ATT_ID);
- if (columnInfo && (columnInfo.shi === "BOTH" ||columnInfo.shi === "OUT" ||columnInfo.shi === null )) {
+ if (columnInfo && (columnInfo.shi === "BOTH" || columnInfo.shi === "OUT" || columnInfo.shi === null)) {
if (columnInfo.type === "NUMBER") {
if (attr.VALUE !== undefined && attr.VALUE !== null) {
if (typeof attr.VALUE === 'string') {
@@ -512,9 +608,46 @@ export async function importTagsFromSEDP(
tagObject[attr.ATT_ID] = attr.VALUE;
}
}
+
+ // registerMatched에서 해당 SEDP_ATT_ID의 DUE_DATE 찾기
+ if (registerMatched && Array.isArray(registerMatched)) {
+ const matchedAttribute = registerMatched.find(
+ regAttr => regAttr.SEDP_ATT_ID === attr.ATT_ID
+ );
+
+ if (matchedAttribute && matchedAttribute.DUE_DATE) {
+ try {
+ const dueDate = new Date(matchedAttribute.DUE_DATE);
+
+ // 유효한 날짜인지 확인
+ if (!isNaN(dueDate.getTime())) {
+ // 첫 번째 DUE_DATE이거나 현재까지 찾은 것보다 더 늦은 날짜인 경우 업데이트
+ if (!latestDueDate || dueDate > latestDueDate) {
+ latestDueDate = dueDate;
+ }
+ }
+ } catch (dateError) {
+ console.warn(`Invalid DUE_DATE format for ${attr.ATT_ID}: ${matchedAttribute.DUE_DATE}`);
+ }
+ }
+ }
+
}
}
+ if (latestDueDate) {
+ // ISO 형식의 문자열로 저장 (또는 원하는 형식으로 변경 가능)
+ tagObject.DUE_DATE = latestDueDate.toISOString();
+
+ // 또는 YYYY-MM-DD 형식을 원한다면:
+ // tagObject.DUE_DATE = latestDueDate.toISOString().split('T')[0];
+
+ // 또는 원본 형식 그대로 유지하려면:
+ // tagObject.DUE_DATE = latestDueDate.toISOString().replace('Z', '');
+ }
+
+
+
// 기존 태그가 있는지 확인하고 처리
const existingTag = existingTagMap.get(tagEntry.TAG_IDX);
@@ -550,8 +683,14 @@ export async function importTagsFromSEDP(
continue;
}
+ if (key === "DUE_DATE" && tagObject[key] !== existingTag.data[key]) {
+ updates[key] = tagObject[key];
+ hasUpdates = true;
+ continue;
+ }
+
const columnInfo = columnsJSON.find(col => col.key === key);
- if (columnInfo && (columnInfo.shi === "BOTH" ||columnInfo.shi === "OUT" ||columnInfo.shi === null )) {
+ if (columnInfo && (columnInfo.shi === "BOTH" || columnInfo.shi === "OUT" || columnInfo.shi === null)) {
if (existingTag.data[key] !== tagObject[key]) {
updates[key] = tagObject[key];
hasUpdates = true;
diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts
index 34c274f5..b69ab71c 100644
--- a/lib/tbe-last/service.ts
+++ b/lib/tbe-last/service.ts
@@ -49,7 +49,7 @@ export async function getAllTBELast(input: GetTBELastSchema) {
}
// 최종 WHERE
- const finalWhere = and(advancedWhere, globalWhere);
+ const finalWhere = and(advancedWhere, globalWhere, ne(tbeLastView.status,"생성중"));
// 정렬
const orderBy = input.sort?.length
diff --git a/lib/vendor-document-list/ship/send-to-shi-button.tsx b/lib/vendor-document-list/ship/send-to-shi-button.tsx
index 532aabf5..52874702 100644
--- a/lib/vendor-document-list/ship/send-to-shi-button.tsx
+++ b/lib/vendor-document-list/ship/send-to-shi-button.tsx
@@ -36,8 +36,8 @@ interface SendToSHIButtonProps {
projectType: "ship" | "plant"
}
-export function SendToSHIButton({
- documents = [],
+export function SendToSHIButton({
+ documents = [],
onSyncComplete,
projectType
}: SendToSHIButtonProps) {
@@ -51,13 +51,13 @@ export function SendToSHIButton({
const { t } = useTranslation(lng, "engineering")
const targetSystem = projectType === 'ship' ? "DOLCE" : "SWP"
-
+
// 문서에서 유효한 계약 ID 목록 추출 (projectId 사용)
const documentsContractIds = React.useMemo(() => {
const validIds = documents
.map(doc => (doc as any).projectId)
.filter((id): id is number => typeof id === 'number' && id > 0)
-
+
const uniqueIds = [...new Set(validIds)]
return uniqueIds.sort()
}, [documents])
@@ -66,7 +66,7 @@ export function SendToSHIButton({
// ✅ 클라이언트 전용 Hook 사용 (서버 사이드 렌더링 호환)
const { contractStatuses, totalStats, refetchAll } = useClientSyncStatus(
- documentsContractIds,
+ documentsContractIds,
targetSystem
)
@@ -79,14 +79,14 @@ export function SendToSHIButton({
toast.info(t('shiSync.messages.noContractsToSync'))
return
}
-
+
setSyncProgress(0)
let successfulSyncs = 0
let failedSyncs = 0
let totalSuccessCount = 0
let totalFailureCount = 0
const errors: string[] = []
-
+
try {
// 동기화 가능한 계약들만 필터링
const contractsToSync = contractStatuses.filter(({ syncStatus, error }) => {
@@ -112,14 +112,14 @@ export function SendToSHIButton({
for (let i = 0; i < contractsToSync.length; i++) {
const { projectId } = contractsToSync[i]
setCurrentSyncingContract(projectId)
-
+
try {
console.log(`Syncing contract ${projectId}...`)
- const result = await triggerSync({
- projectId,
- targetSystem
+ const result = await triggerSync({
+ projectId,
+ targetSystem
})
-
+
if (result?.success) {
successfulSyncs++
totalSuccessCount += result.successCount || 0
@@ -143,12 +143,12 @@ export function SendToSHIButton({
}
setCurrentSyncingContract(null)
-
+
// 결과 처리 및 토스트 표시
setTimeout(() => {
setSyncProgress(0)
setIsDialogOpen(false)
-
+
if (failedSyncs === 0) {
toast.success(
t('shiSync.messages.allSyncCompleted', { successCount: totalSuccessCount }),
@@ -161,12 +161,12 @@ export function SendToSHIButton({
)
} else if (successfulSyncs > 0) {
toast.warning(
- t('shiSync.messages.partialSyncCompleted', {
- successfulCount: successfulSyncs,
- failedCount: failedSyncs
+ t('shiSync.messages.partialSyncCompleted', {
+ successfulCount: successfulSyncs,
+ failedCount: failedSyncs
}),
{
- description: errors.slice(0, 3).join(', ') +
+ description: errors.slice(0, 3).join(', ') +
(errors.length > 3 ? t('shiSync.messages.andMore') : '')
}
)
@@ -178,16 +178,16 @@ export function SendToSHIButton({
}
)
}
-
+
// 모든 contract 상태 갱신
refetchAll()
onSyncComplete?.()
}, 500)
-
+
} catch (error) {
setSyncProgress(0)
setCurrentSyncingContract(null)
-
+
const errorMessage = syncUtils.formatError(error as Error)
toast.error(t('shiSync.messages.syncFailed'), {
description: errorMessage
@@ -259,8 +259,8 @@ export function SendToSHIButton({
)}
<span className="hidden sm:inline">{t('shiSync.buttons.sendToSHI')}</span>
{totalStats.totalPending > 0 && (
- <Badge
- variant="destructive"
+ <Badge
+ variant="destructive"
className="h-5 w-5 p-0 text-xs flex items-center justify-center ml-1"
>
{totalStats.totalPending}
@@ -269,7 +269,7 @@ export function SendToSHIButton({
</Button>
</div>
</PopoverTrigger>
-
+
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<div className="space-y-2">
@@ -289,16 +289,16 @@ export function SendToSHIButton({
)}
</Button>
</div>
-
+
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{t('shiSync.labels.overallStatus')}</span>
{getSyncStatusBadge()}
</div>
-
+
<div className="text-xs text-muted-foreground">
- {t('shiSync.descriptions.targetInfo', {
- contractCount: documentsContractIds.length,
- targetSystem
+ {t('shiSync.descriptions.targetInfo', {
+ contractCount: documentsContractIds.length,
+ targetSystem
})}
</div>
</div>
@@ -311,8 +311,8 @@ export function SendToSHIButton({
{t('shiSync.descriptions.statusCheckError')}
{process.env.NODE_ENV === 'development' && (
<div className="text-xs mt-1 font-mono">
- Debug: {t('shiSync.descriptions.contractsWithError', {
- count: contractStatuses.filter(({ error }) => error).length
+ Debug: {t('shiSync.descriptions.contractsWithError', {
+ count: contractStatuses.filter(({ error }) => error).length
})}
</div>
)}
@@ -324,7 +324,7 @@ export function SendToSHIButton({
{!totalStats.hasError && documentsContractIds.length > 0 && (
<div className="space-y-3">
<Separator />
-
+
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="text-center">
<div className="text-muted-foreground">{t('shiSync.labels.pending')}</div>
@@ -409,7 +409,7 @@ export function SendToSHIButton({
</>
)}
</Button>
-
+
<Button
variant="outline"
size="sm"
@@ -451,12 +451,12 @@ export function SendToSHIButton({
<span>{t('shiSync.labels.syncTarget')}</span>
<span className="font-medium">{t('shiSync.labels.itemCount', { count: totalStats.totalPending })}</span>
</div>
-
+
<div className="flex items-center justify-between text-sm">
<span>{t('shiSync.labels.targetContracts')}</span>
<span className="font-medium">{t('shiSync.labels.contractCount', { count: documentsContractIds.length })}</span>
</div>
-
+
<div className="text-xs text-muted-foreground">
{t('shiSync.descriptions.includesChanges')}
</div>
diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx
index 206846df..fd6da145 100644
--- a/lib/vendors/table/request-pq-dialog.tsx
+++ b/lib/vendors/table/request-pq-dialog.tsx
@@ -709,9 +709,22 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
date={dueDate ? new Date(dueDate) : undefined}
onSelect={(date?: Date) => {
if (date) {
+ // 현재 날짜 기준으로 이전 날짜는 선택 불가능
+ const today = new Date()
+ today.setHours(0, 0, 0, 0) // 오늘 날짜의 시작 시간으로 설정
+
+ const selectedDate = new Date(date)
+ selectedDate.setHours(0, 0, 0, 0) // 선택된 날짜의 시작 시간으로 설정
+
+ if (selectedDate < today) {
+ toast.error("마감일은 오늘 날짜 이후로 선택해주세요.")
+ return
+ } else {
+
// 한국 시간대로 날짜 변환 (UTC 변환으로 인한 날짜 변경 방지)
const kstDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
setDueDate(kstDate.toISOString().slice(0, 10))
+ }
} else {
setDueDate("")
}