diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-12 11:32:59 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-12 11:32:59 +0000 |
| commit | 4b76297a7b9f36fdbffe58b152e5ba418b0e6237 (patch) | |
| tree | 782652a769832729174e2a5a796febb644fe735b /components/form-data | |
| parent | 20b1a8e6e39b3adf058b32f1b2e219ee93a9f1c7 (diff) | |
(대표님) S-EDP 관련 components/form-data
Diffstat (limited to 'components/form-data')
| -rw-r--r-- | components/form-data/form-data-table-columns.tsx | 50 | ||||
| -rw-r--r-- | components/form-data/sedp-compare-dialog.tsx | 434 | ||||
| -rw-r--r-- | components/form-data/sedp-excel-download.tsx | 213 | ||||
| -rw-r--r-- | components/form-data/update-form-sheet.tsx | 82 |
4 files changed, 554 insertions, 225 deletions
diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index 4db3a724..a1fbcae1 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -38,6 +38,8 @@ export interface DataTableColumnJSON { options?: string[]; uom?: string; uomId?: string; + shi?: boolean; + } /** * getColumns 함수에 필요한 props @@ -78,17 +80,33 @@ export function getColumns<TData extends object>({ minWidth: 80, paddingFactor: 1.2, maxWidth: col.key === "TAG_NO" ? 120 : 150, + isReadOnly: col.shi === true, // shi 정보를 메타데이터에 저장 }, // (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능 cell: ({ row }) => { const cellValue = row.getValue(col.key); + + // shi 속성이 true인 경우 적용할 스타일 + const isReadOnly = col.shi === true; + const readOnlyClass = isReadOnly ? "read-only-cell" : ""; + + // 읽기 전용 셀의 스타일 (인라인 스타일과 클래스 동시 적용) + const cellStyle = isReadOnly + ? { backgroundColor: '#f5f5f5', color: '#666', cursor: 'not-allowed' } + : {}; // 데이터 타입별 처리 switch (col.type) { case "NUMBER": // 예: number인 경우 콤마 등 표시 return ( - <div>{cellValue ? Number(cellValue).toLocaleString() : ""}</div> + <div + className={readOnlyClass} + style={cellStyle} + title={isReadOnly ? "읽기 전용 필드입니다" : ""} + > + {cellValue ? Number(cellValue).toLocaleString() : ""} + </div> ); // case "date": @@ -101,11 +119,27 @@ export function getColumns<TData extends object>({ case "LIST": // 예: select인 경우 label만 표시 - return <div>{String(cellValue ?? "")}</div>; + return ( + <div + className={readOnlyClass} + style={cellStyle} + title={isReadOnly ? "읽기 전용 필드입니다" : ""} + > + {String(cellValue ?? "")} + </div> + ); case "STRING": default: - return <div>{String(cellValue ?? "")}</div>; + return ( + <div + className={readOnlyClass} + style={cellStyle} + title={isReadOnly ? "읽기 전용 필드입니다" : ""} + > + {String(cellValue ?? "")} + </div> + ); } }, })); @@ -127,7 +161,15 @@ export function getColumns<TData extends object>({ </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-40"> <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} + onSelect={() => { + // 행에 있는 모든 필드가 읽기 전용인지 확인할 수도 있습니다 (선택 사항) + // const allColumnsReadOnly = columnsJSON.every(col => col.shi === true); + // if(allColumnsReadOnly) { + // toast.info("이 항목은 읽기 전용입니다."); + // return; + // } + setRowAction({ row, type: "update" }); + }} > Edit </DropdownMenuItem> diff --git a/components/form-data/sedp-compare-dialog.tsx b/components/form-data/sedp-compare-dialog.tsx index 461a3630..37fe18ed 100644 --- a/components/form-data/sedp-compare-dialog.tsx +++ b/components/form-data/sedp-compare-dialog.tsx @@ -3,11 +3,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Loader, RefreshCw, AlertCircle, CheckCircle, Info } from "lucide-react"; +import { Loader, RefreshCw, AlertCircle, CheckCircle, Info, EyeOff } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; import { DataTableColumnJSON } from "./form-data-table-columns"; import { ExcelDownload } from "./sedp-excel-download"; +import { Switch } from "../ui/switch"; interface SEDPCompareDialogProps { isOpen: boolean; @@ -38,12 +39,12 @@ const DisplayValue = ({ value, uom, isSedp = false }: { value: any; uom?: string if (value === "" || value === null || value === undefined) { return <span>(empty)</span>; } - + // SEDP 값은 UOM을 표시하지 않음 (이미 포함되어 있다고 가정) if (isSedp) { return <span>{value}</span>; } - + // 로컬 값은 UOM과 함께 표시 return ( <span> @@ -88,27 +89,72 @@ export function SEDPCompareDialog({ const [comparisonResults, setComparisonResults] = React.useState<ComparisonResult[]>([]); const [activeTab, setActiveTab] = React.useState("all"); const [isExporting, setIsExporting] = React.useState(false); - + const [missingTags, setMissingTags] = React.useState<{ + localOnly: { tagNo: string; tagDesc: string }[]; + sedpOnly: { tagNo: string; tagDesc: string }[]; + }>( + { localOnly: [], sedpOnly: [] } + ); + // 추가: 차이점만 표시하는 옵션 + const [showOnlyDifferences, setShowOnlyDifferences] = React.useState(true); + // Stats for summary const totalTags = comparisonResults.length; const matchingTags = comparisonResults.filter(r => r.isMatching).length; const nonMatchingTags = totalTags - matchingTags; + const totalMissingTags = missingTags.localOnly.length + missingTags.sedpOnly.length; // Get column label map and UOM map for better display const { columnLabelMap, columnUomMap } = React.useMemo(() => { const labelMap: Record<string, string> = {}; const uomMap: Record<string, string> = {}; - + columnsJSON.forEach(col => { labelMap[col.key] = col.displayLabel || col.label; if (col.uom) { uomMap[col.key] = col.uom; } }); - + return { columnLabelMap: labelMap, columnUomMap: uomMap }; }, [columnsJSON]); + // Filter results based on active tab + const filteredResults = React.useMemo(() => { + switch (activeTab) { + case "matching": + return comparisonResults.filter(r => r.isMatching); + case "differences": + return comparisonResults.filter(r => !r.isMatching); + case "all": + default: + return comparisonResults; + } + }, [comparisonResults, activeTab]); + + // 변경: 표시할 컬럼 결정 (차이가 있는 컬럼만 or 모든 컬럼) + const columnsToDisplay = React.useMemo(() => { + // 기본 컬럼 (TAG_NO, TAG_DESC 제외) + const columns = columnsJSON.filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC"); + + if (!showOnlyDifferences) { + return columns; // 모든 컬럼 표시 + } + + // 하나라도 차이가 있는 속성만 필터링 + const columnsWithDifferences = new Set<string>(); + comparisonResults.forEach(result => { + result.attributes.forEach(attr => { + if (!attr.isMatching) { + columnsWithDifferences.add(attr.key); + } + }); + }); + + // 차이가 있는 컬럼만 반환 + return columns.filter(col => columnsWithDifferences.has(col.key)); + }, [columnsJSON, comparisonResults, showOnlyDifferences]); + const fetchAndCompareData = React.useCallback(async () => { if (!projectCode || !formCode) { toast.error("Project code or form code is missing"); @@ -117,110 +163,120 @@ export function SEDPCompareDialog({ try { setIsLoading(true); - + // Fetch data from SEDP API const sedpData = await fetchTagDataFromSEDP(projectCode, formCode); - + // Get the table name from the response const tableName = Object.keys(sedpData)[0]; const sedpTagEntries = sedpData[tableName] || []; - + // Create a map of SEDP data by TAG_NO for quick lookup const sedpTagMap = new Map(); sedpTagEntries.forEach((entry: any) => { const tagNo = entry.TAG_NO; const attributesMap = new Map(); - + // Convert attributes array to map for easier access if (Array.isArray(entry.ATTRIBUTES)) { entry.ATTRIBUTES.forEach((attr: any) => { attributesMap.set(attr.ATT_ID, attr.VALUE); }); } - + sedpTagMap.set(tagNo, { tagDesc: entry.TAG_DESC, attributes: attributesMap }); }); - - // Compare with local table data - const results: ComparisonResult[] = tableData.map(localItem => { - const tagNo = localItem.TAG_NO; - const sedpItem = sedpTagMap.get(tagNo); - - // If tag not found in SEDP data - if (!sedpItem) { + + // Create sets for finding missing tags + const localTagNos = new Set(tableData.map(item => item.TAG_NO)); + const sedpTagNos = new Set(sedpTagMap.keys()); + + // Find missing tags + const localOnlyTags = tableData + .filter(item => !sedpTagMap.has(item.TAG_NO)) + .map(item => ({ tagNo: item.TAG_NO, tagDesc: item.TAG_DESC || "" })); + + const sedpOnlyTags = Array.from(sedpTagMap.entries()) + .filter(([tagNo]) => !localTagNos.has(tagNo)) + .map(([tagNo, data]) => ({ tagNo, tagDesc: data.tagDesc || "" })); + + setMissingTags({ + localOnly: localOnlyTags, + sedpOnly: sedpOnlyTags + }); + + // Compare with local table data (only for tags that exist in both systems) + const results: ComparisonResult[] = tableData + .filter(localItem => sedpTagMap.has(localItem.TAG_NO)) + .map(localItem => { + const tagNo = localItem.TAG_NO; + const sedpItem = sedpTagMap.get(tagNo); + + // Compare attributes + const attributeComparisons = columnsJSON + .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC") + .map(col => { + const localValue = localItem[col.key]; + const sedpValue = sedpItem.attributes.get(col.key); + const uom = columnUomMap[col.key]; + + // Compare values (with type handling) + let isMatching = false; + + // Special case: Empty SEDP value and 0 local value + if ((sedpValue === "" || sedpValue === null || sedpValue === undefined) && + (localValue === 0 || localValue === "0")) { + isMatching = true; + } else { + // Standard string comparison for other cases + const normalizedLocal = localValue === undefined || localValue === null ? "" : String(localValue).trim(); + const normalizedSedp = sedpValue === undefined || sedpValue === null ? "" : String(sedpValue).trim(); + isMatching = normalizedLocal === normalizedSedp; + } + + return { + key: col.key, + label: columnLabelMap[col.key] || col.key, + localValue, + sedpValue, + isMatching, + uom + }; + }); + + // Item is matching if all attributes match + const isItemMatching = attributeComparisons.every(attr => attr.isMatching); + return { tagNo, tagDesc: localItem.TAG_DESC || "", - isMatching: false, - attributes: columnsJSON - .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC") - .map(col => ({ - key: col.key, - label: columnLabelMap[col.key] || col.key, - localValue: localItem[col.key], - sedpValue: null, - isMatching: false, - uom: columnUomMap[col.key] - })) + isMatching: isItemMatching, + attributes: attributeComparisons }; - } - - // Compare attributes - const attributeComparisons = columnsJSON - .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC") - .map(col => { - const localValue = localItem[col.key]; - const sedpValue = sedpItem.attributes.get(col.key); - const uom = columnUomMap[col.key]; - - // Compare values (with type handling) - let isMatching = false; - - // 문자열 비교 - // Normalize empty values - const normalizedLocal = localValue === undefined || localValue === null ? "" : String(localValue).trim(); - const normalizedSedp = sedpValue === undefined || sedpValue === null ? "" : String(sedpValue).trim(); - isMatching = normalizedLocal === normalizedSedp; - - - return { - key: col.key, - label: columnLabelMap[col.key] || col.key, - localValue, - sedpValue, - isMatching, - uom - }; - }); - - // Item is matching if all attributes match - const isItemMatching = attributeComparisons.every(attr => attr.isMatching); - - return { - tagNo, - tagDesc: localItem.TAG_DESC || "", - isMatching: isItemMatching, - attributes: attributeComparisons - }; - }); - + }); + setComparisonResults(results); - + // Show summary in toast const matchCount = results.filter(r => r.isMatching).length; const nonMatchCount = results.length - matchCount; - + const missingCount = localOnlyTags.length + sedpOnlyTags.length; + + if (missingCount > 0) { + toast.error(`Found ${missingCount} missing tags between systems`); + } + if (nonMatchCount > 0) { toast.warning(`Found ${nonMatchCount} tags with differences`); - } else if (results.length > 0) { + } else if (results.length > 0 && missingCount === 0) { toast.success(`All ${results.length} tags match with SEDP data`); - } else { + } else if (results.length === 0 && missingCount === 0) { toast.info("No tags to compare"); } - + } catch (error) { console.error("SEDP comparison error:", error); toast.error(`Failed to compare with SEDP: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -228,41 +284,38 @@ export function SEDPCompareDialog({ setIsLoading(false); } }, [projectCode, formCode, tableData, columnsJSON, fetchTagDataFromSEDP, columnLabelMap, columnUomMap]); - + // Fetch data when dialog opens React.useEffect(() => { if (isOpen) { fetchAndCompareData(); } }, [isOpen, fetchAndCompareData]); - - // Filter results based on active tab - const filteredResults = React.useMemo(() => { - switch (activeTab) { - case "matching": - return comparisonResults.filter(r => r.isMatching); - case "differences": - return comparisonResults.filter(r => !r.isMatching); - case "all": - default: - return comparisonResults; - } - }, [comparisonResults, activeTab]); return ( <Dialog open={isOpen} onOpenChange={onClose}> <DialogContent className="max-w-5xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogHeader> - <DialogTitle className="flex items-center justify-between"> - <span>SEDP 데이터 비교</span> + <DialogTitle className="mb-2">SEDP 데이터 비교</DialogTitle> + <div className="flex items-center justify-between gap-2 pr-8"> + <div className="flex items-center gap-2"> + <Switch + checked={showOnlyDifferences} + onCheckedChange={setShowOnlyDifferences} + id="show-differences" + /> + <label htmlFor="show-differences" className="text-sm cursor-pointer"> + 차이가 있는 항목만 표시 + </label> + </div> <div className="flex items-center gap-2"> - <Badge variant={matchingTags === totalTags ? "default" : "destructive"}> - {matchingTags} / {totalTags} 일치 + <Badge variant={matchingTags === totalTags && totalMissingTags === 0 ? "default" : "destructive"}> + {matchingTags} / {totalTags} 일치 {totalMissingTags > 0 ? `(${totalMissingTags} 누락)` : ''} </Badge> - <Button - variant="outline" - size="sm" - onClick={fetchAndCompareData} + <Button + variant="outline" + size="sm" + onClick={fetchAndCompareData} disabled={isLoading} > {isLoading ? ( @@ -273,47 +326,125 @@ export function SEDPCompareDialog({ <span className="ml-2">새로고침</span> </Button> </div> - </DialogTitle> + </div> </DialogHeader> - + {/* 범례 추가 */} <div className="mb-4"> <ColorLegend /> </div> - + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden"> <TabsList> <TabsTrigger value="all">전체 태그 ({totalTags})</TabsTrigger> <TabsTrigger value="differences">차이 있음 ({nonMatchingTags})</TabsTrigger> <TabsTrigger value="matching">일치함 ({matchingTags})</TabsTrigger> + <TabsTrigger value="missing" className={totalMissingTags > 0 ? "text-red-500" : ""}> + 누락된 태그 ({totalMissingTags}) + </TabsTrigger> </TabsList> - + <TabsContent value={activeTab} className="flex-1 overflow-auto"> {isLoading ? ( <div className="flex items-center justify-center h-full"> <Loader className="h-8 w-8 animate-spin mr-2" /> <span>데이터 비교 중...</span> </div> + ) : activeTab === "missing" ? ( + // Missing tags tab content + <div className="space-y-6"> + {missingTags.localOnly.length > 0 && ( + <div> + <h3 className="text-sm font-medium mb-2">로컬에만 있는 태그 ({missingTags.localOnly.length})</h3> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[180px]">Tag Number</TableHead> + <TableHead>Tag Description</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {missingTags.localOnly.map((tag) => ( + <TableRow key={tag.tagNo} className="bg-yellow-50 dark:bg-yellow-950/20"> + <TableCell className="font-medium">{tag.tagNo}</TableCell> + <TableCell>{tag.tagDesc}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + )} + + {missingTags.sedpOnly.length > 0 && ( + <div> + <h3 className="text-sm font-medium mb-2">SEDP에만 있는 태그 ({missingTags.sedpOnly.length})</h3> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[180px]">Tag Number</TableHead> + <TableHead>Tag Description</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {missingTags.sedpOnly.map((tag) => ( + <TableRow key={tag.tagNo} className="bg-blue-50 dark:bg-blue-950/20"> + <TableCell className="font-medium">{tag.tagNo}</TableCell> + <TableCell>{tag.tagDesc}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + )} + + {totalMissingTags === 0 && ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + 모든 태그가 양쪽 시스템에 존재합니다 + </div> + )} + </div> ) : filteredResults.length > 0 ? ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[180px]">Tag Number</TableHead> - <TableHead className="w-[200px]">Tag Description</TableHead> - <TableHead className="w-[120px]">상태</TableHead> - <TableHead>차이점</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {filteredResults.map((result) => { - // Find differences to display - const differences = result.attributes.filter(attr => !attr.isMatching); - - return ( + // 개선된 테이블 구조 + <div className="overflow-x-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="sticky left-0 z-10 bg-background w-[180px]">Tag Number</TableHead> + <TableHead className="sticky left-[180px] z-10 bg-background w-[200px]">Tag Description</TableHead> + <TableHead className="sticky left-[380px] z-10 bg-background w-[100px]">상태</TableHead> + + {/* 동적으로 속성 열 헤더 생성 */} + {columnsToDisplay.length > 0 ? ( + columnsToDisplay.map(col => ( + <TableHead key={col.key} className="min-w-[150px]"> + {columnLabelMap[col.key] || col.key} + {columnUomMap[col.key] && ( + <span className="text-xs text-muted-foreground ml-1"> + ({columnUomMap[col.key]}) + </span> + )} + </TableHead> + )) + ) : ( + <TableHead> + <div className="flex items-center justify-center text-muted-foreground"> + <EyeOff className="h-4 w-4 mr-2" /> + <span>차이가 있는 항목이 없습니다</span> + </div> + </TableHead> + )} + </TableRow> + </TableHeader> + <TableBody> + {filteredResults.map((result) => ( <TableRow key={result.tagNo} className={!result.isMatching ? "bg-muted/30" : ""}> - <TableCell className="font-medium">{result.tagNo}</TableCell> - <TableCell>{result.tagDesc}</TableCell> - <TableCell> + <TableCell className="sticky left-0 z-10 bg-background font-medium"> + {result.tagNo} + </TableCell> + <TableCell className="sticky left-[180px] z-10 bg-background"> + {result.tagDesc} + </TableCell> + <TableCell className="sticky left-[380px] z-10 bg-background"> {result.isMatching ? ( <Badge variant="default" className="flex items-center gap-1"> <CheckCircle className="h-3 w-3" /> @@ -326,30 +457,44 @@ export function SEDPCompareDialog({ </Badge> )} </TableCell> - <TableCell> - {differences.length > 0 ? ( - <div className="space-y-1"> - {differences.map((diff) => ( - <div key={diff.key} className="text-sm"> - <span className="font-medium">{diff.label}: </span> - <span className="line-through text-red-500 mr-2"> - <DisplayValue value={diff.localValue} uom={diff.uom} isSedp={false} /> - </span> - <span className="text-green-500"> - <DisplayValue value={diff.sedpValue} uom={diff.uom} isSedp={true} /> - </span> - </div> - ))} - </div> - ) : ( + + {/* 각 속성에 대한 셀 동적 생성 */} + {columnsToDisplay.length > 0 ? ( + columnsToDisplay.map(col => { + const attr = result.attributes.find(a => a.key === col.key); + + if (!attr) return <TableCell key={col.key}>-</TableCell>; + + return ( + <TableCell + key={col.key} + className={!attr.isMatching ? "bg-muted/50" : ""} + > + {attr.isMatching ? ( + <DisplayValue value={attr.localValue} uom={attr.uom} /> + ) : ( + <div className="flex flex-col gap-1"> + <div className="line-through text-red-500"> + <DisplayValue value={attr.localValue} uom={attr.uom} isSedp={false} /> + </div> + <div className="text-green-500"> + <DisplayValue value={attr.sedpValue} uom={attr.uom} isSedp={true} /> + </div> + </div> + )} + </TableCell> + ); + }) + ) : ( + <TableCell> <span className="text-muted-foreground">모든 값이 일치합니다</span> - )} - </TableCell> + </TableCell> + )} </TableRow> - ); - })} - </TableBody> - </Table> + ))} + </TableBody> + </Table> + </div> ) : ( <div className="flex items-center justify-center h-full text-muted-foreground"> 현재 필터에 맞는 태그가 없습니다 @@ -357,12 +502,13 @@ export function SEDPCompareDialog({ )} </TabsContent> </Tabs> - + <DialogFooter className="flex justify-between items-center gap-4 pt-4 border-t"> <ExcelDownload comparisonResults={comparisonResults} + missingTags={missingTags} formCode={formCode} - disabled={isLoading || nonMatchingTags === 0} + disabled={isLoading || (nonMatchingTags === 0 && totalMissingTags === 0)} /> <Button onClick={onClose}>닫기</Button> </DialogFooter> diff --git a/components/form-data/sedp-excel-download.tsx b/components/form-data/sedp-excel-download.tsx index 70f5c46a..24e1003d 100644 --- a/components/form-data/sedp-excel-download.tsx +++ b/components/form-data/sedp-excel-download.tsx @@ -18,11 +18,15 @@ interface ExcelDownloadProps { uom?: string; }>; }>; + missingTags: { + localOnly: Array<{ tagNo: string; tagDesc: string }>; + sedpOnly: Array<{ tagNo: string; tagDesc: string }>; + }; formCode: string; disabled: boolean; } -export function ExcelDownload({ comparisonResults, formCode, disabled }: ExcelDownloadProps) { +export function ExcelDownload({ comparisonResults, missingTags, formCode, disabled }: ExcelDownloadProps) { const [isExporting, setIsExporting] = React.useState(false); // Function to generate and download Excel file with differences @@ -32,8 +36,9 @@ export function ExcelDownload({ comparisonResults, formCode, disabled }: ExcelDo // Get only items with differences const itemsWithDifferences = comparisonResults.filter(item => !item.isMatching); + const hasMissingTags = missingTags.localOnly.length > 0 || missingTags.sedpOnly.length > 0; - if (itemsWithDifferences.length === 0) { + if (itemsWithDifferences.length === 0 && !hasMissingTags) { toast.info("차이가 없어 다운로드할 내용이 없습니다"); return; } @@ -43,65 +48,122 @@ export function ExcelDownload({ comparisonResults, formCode, disabled }: ExcelDo workbook.creator = 'SEDP Compare Tool'; workbook.created = new Date(); - // Add a worksheet - const worksheet = workbook.addWorksheet('SEDP Differences'); - - // Add headers - worksheet.columns = [ - { header: 'Tag Number', key: 'tagNo', width: 20 }, - { header: 'Tag Description', key: 'tagDesc', width: 30 }, - { header: 'Attribute', key: 'attribute', width: 25 }, - { header: 'Local Value', key: 'localValue', width: 20 }, - { header: 'SEDP Value', key: 'sedpValue', width: 20 } - ]; - - // Style the header row - const headerRow = worksheet.getRow(1); - headerRow.eachCell((cell) => { - cell.font = { bold: true }; - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE0E0E0' } - }; - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - }; - }); - - // Add data rows - let rowIndex = 2; - itemsWithDifferences.forEach(item => { - const differences = item.attributes.filter(attr => !attr.isMatching); + // Add a worksheet for attribute differences + if (itemsWithDifferences.length > 0) { + const worksheet = workbook.addWorksheet('속성 차이'); - if (differences.length === 0) return; + // Add headers + worksheet.columns = [ + { header: 'Tag Number', key: 'tagNo', width: 20 }, + { header: 'Tag Description', key: 'tagDesc', width: 30 }, + { header: 'Attribute', key: 'attribute', width: 25 }, + { header: 'Local Value', key: 'localValue', width: 20 }, + { header: 'SEDP Value', key: 'sedpValue', width: 20 } + ]; - differences.forEach(diff => { - const row = worksheet.getRow(rowIndex++); + // Style the header row + const headerRow = worksheet.getRow(1); + headerRow.eachCell((cell) => { + cell.font = { bold: true }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // Add data rows + let rowIndex = 2; + itemsWithDifferences.forEach(item => { + const differences = item.attributes.filter(attr => !attr.isMatching); + + if (differences.length === 0) return; - // Format local value with UOM - const localDisplay = diff.localValue === null || diff.localValue === undefined || diff.localValue === '' - ? "(empty)" - : diff.uom ? `${diff.localValue} ${diff.uom}` : diff.localValue; + differences.forEach(diff => { + const row = worksheet.getRow(rowIndex++); + + // Format local value with UOM + const localDisplay = diff.localValue === null || diff.localValue === undefined || diff.localValue === '' + ? "(empty)" + : diff.uom ? `${diff.localValue} ${diff.uom}` : diff.localValue; + + // SEDP value is displayed as-is + const sedpDisplay = diff.sedpValue === null || diff.sedpValue === undefined || diff.sedpValue === '' + ? "(empty)" + : diff.sedpValue; + + // Set cell values + row.getCell('tagNo').value = item.tagNo; + row.getCell('tagDesc').value = item.tagDesc; + row.getCell('attribute').value = diff.label; + row.getCell('localValue').value = localDisplay; + row.getCell('sedpValue').value = sedpDisplay; - // SEDP value is displayed as-is - const sedpDisplay = diff.sedpValue === null || diff.sedpValue === undefined || diff.sedpValue === '' - ? "(empty)" - : diff.sedpValue; + // Style the row + row.getCell('localValue').font = { color: { argb: 'FFFF0000' } }; // Red for local value + row.getCell('sedpValue').font = { color: { argb: 'FF008000' } }; // Green for SEDP value + + // Add borders + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); - // Set cell values - row.getCell('tagNo').value = item.tagNo; - row.getCell('tagDesc').value = item.tagDesc; - row.getCell('attribute').value = diff.label; - row.getCell('localValue').value = localDisplay; - row.getCell('sedpValue').value = sedpDisplay; + // Add a blank row after each tag for better readability + rowIndex++; + }); + } + + // Add a worksheet for missing tags if there are any + if (hasMissingTags) { + const missingWorksheet = workbook.addWorksheet('누락된 태그'); + + // Add headers + missingWorksheet.columns = [ + { header: 'Tag Number', key: 'tagNo', width: 20 }, + { header: 'Tag Description', key: 'tagDesc', width: 30 }, + { header: 'Status', key: 'status', width: 20 } + ]; + + // Style the header row + const headerRow = missingWorksheet.getRow(1); + headerRow.eachCell((cell) => { + cell.font = { bold: true }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // Add local-only tags + let rowIndex = 2; + missingTags.localOnly.forEach(tag => { + const row = missingWorksheet.getRow(rowIndex++); - // Style the row - row.getCell('localValue').font = { color: { argb: 'FFFF0000' } }; // Red for local value - row.getCell('sedpValue').font = { color: { argb: 'FF008000' } }; // Green for SEDP value + row.getCell('tagNo').value = tag.tagNo; + row.getCell('tagDesc').value = tag.tagDesc; + row.getCell('status').value = '로컬에만 존재'; + + // Style the status cell + row.getCell('status').font = { color: { argb: 'FFFF8C00' } }; // Orange for local-only // Add borders row.eachCell((cell) => { @@ -114,9 +176,33 @@ export function ExcelDownload({ comparisonResults, formCode, disabled }: ExcelDo }); }); - // Add a blank row after each tag for better readability - rowIndex++; - }); + // Add a blank row + if (missingTags.localOnly.length > 0 && missingTags.sedpOnly.length > 0) { + rowIndex++; + } + + // Add SEDP-only tags + missingTags.sedpOnly.forEach(tag => { + const row = missingWorksheet.getRow(rowIndex++); + + row.getCell('tagNo').value = tag.tagNo; + row.getCell('tagDesc').value = tag.tagDesc; + row.getCell('status').value = 'SEDP에만 존재'; + + // Style the status cell + row.getCell('status').font = { color: { argb: 'FF0000FF' } }; // Blue for SEDP-only + + // Add borders + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + } // Generate Excel file const buffer = await workbook.xlsx.writeBuffer(); @@ -128,7 +214,7 @@ export function ExcelDownload({ comparisonResults, formCode, disabled }: ExcelDo const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `SEDP_Differences_${formCode}_${new Date().toISOString().slice(0, 10)}.xlsx`; + a.download = `SEDP_차이점_${formCode}_${new Date().toISOString().slice(0, 10)}.xlsx`; document.body.appendChild(a); a.click(); @@ -136,7 +222,7 @@ export function ExcelDownload({ comparisonResults, formCode, disabled }: ExcelDo window.URL.revokeObjectURL(url); document.body.removeChild(a); - toast.success("차이점 Excel 다운로드 완료"); + toast.success("Excel 다운로드 완료"); } catch (error) { console.error("Error exporting to Excel:", error); toast.error("Excel 다운로드 실패"); @@ -145,11 +231,16 @@ export function ExcelDownload({ comparisonResults, formCode, disabled }: ExcelDo } }; + // Determine if there are any differences or missing tags + const hasDifferences = comparisonResults.some(item => !item.isMatching); + const hasMissingTags = missingTags && (missingTags.localOnly.length > 0 || missingTags.sedpOnly.length > 0); + const hasExportableContent = hasDifferences || hasMissingTags; + return ( <Button variant="secondary" onClick={handleExportDifferences} - disabled={disabled || isExporting} + disabled={disabled || isExporting || !hasExportableContent} className="flex items-center gap-2" > {isExporting ? ( diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index 27f426c1..6f2a4722 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -4,9 +4,9 @@ import * as React from "react"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; -import { Check, ChevronsUpDown, Loader } from "lucide-react"; +import { Check, ChevronsUpDown, Loader, LockIcon } from "lucide-react"; import { toast } from "sonner"; -import { useRouter } from "next/navigation"; // Add this import +import { useRouter } from "next/navigation"; import { Sheet, @@ -26,6 +26,7 @@ import { FormLabel, FormControl, FormMessage, + FormDescription, } from "@/components/ui/form"; import { Popover, @@ -68,7 +69,7 @@ export function UpdateTagSheet({ ...props }: UpdateTagSheetProps) { const [isPending, startTransition] = React.useTransition(); - const router = useRouter(); // Add router hook + const router = useRouter(); // 1) zod 스키마 const dynamicSchema = React.useMemo(() => { @@ -114,10 +115,19 @@ export function UpdateTagSheet({ async function onSubmit(values: Record<string, any>) { startTransition(async () => { try { + // 제출 전에 읽기 전용 필드를 원본 값으로 복원 + const finalValues = { ...values }; + for (const col of columns) { + if (col.shi || col.key === "TAG_NO" || col.key === "TAG_DESC") { + // 읽기 전용 필드는 원본 값으로 복원 + finalValues[col.key] = rowData?.[col.key] ?? ""; + } + } + const { success, message } = await updateFormDataInDB( formCode, contractItemId, - values + finalValues ); if (!success) { @@ -131,7 +141,7 @@ export function UpdateTagSheet({ // Create a merged object of original rowData and new values const updatedData = { ...rowData, - ...values, + ...finalValues, TAG_NO: rowData?.TAG_NO, }; @@ -157,7 +167,7 @@ export function UpdateTagSheet({ <SheetHeader className="text-left"> <SheetTitle>Update Row</SheetTitle> <SheetDescription> - Modify the fields below and save changes + Modify the fields below and save changes. Fields with <LockIcon className="inline h-3 w-3" /> are read-only. </SheetDescription> </SheetHeader> @@ -169,8 +179,11 @@ export function UpdateTagSheet({ <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4"> <div className="flex flex-col gap-4 pt-2"> {columns.map((col) => { - const isTagNumberField = - col.key === "TAG_NO" || col.key === "TAG_DESC"; + // 읽기 전용 조건 업데이트: shi가 true이거나 TAG_NO/TAG_DESC인 경우 + const isReadOnly = col.shi === true || + col.key === "TAG_NO" || + col.key === "TAG_DESC"; + return ( <FormField key={col.key} @@ -181,18 +194,31 @@ export function UpdateTagSheet({ case "NUMBER": return ( <FormItem> - <FormLabel>{col.displayLabel}</FormLabel> + <FormLabel className="flex items-center"> + {col.displayLabel || col.label} + {isReadOnly && ( + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> + )} + </FormLabel> <FormControl> <Input type="number" - readOnly={isTagNumberField} + readOnly={isReadOnly} onChange={(e) => { const num = parseFloat(e.target.value); field.onChange(isNaN(num) ? "" : num); }} value={field.value ?? ""} + className={cn( + isReadOnly && "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300" + )} /> </FormControl> + {isReadOnly && col.shi && ( + <FormDescription className="text-xs text-gray-500"> + This field is read-only + </FormDescription> + )} <FormMessage /> </FormItem> ); @@ -200,16 +226,22 @@ export function UpdateTagSheet({ case "LIST": return ( <FormItem> - <FormLabel>{col.label}</FormLabel> + <FormLabel className="flex items-center"> + {col.label} + {isReadOnly && ( + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> + )} + </FormLabel> <Popover> <PopoverTrigger asChild> <Button variant="outline" role="combobox" - disabled={isTagNumberField} + disabled={isReadOnly} className={cn( "w-full justify-between", - !field.value && "text-muted-foreground" + !field.value && "text-muted-foreground", + isReadOnly && "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300" )} > {field.value @@ -246,6 +278,11 @@ export function UpdateTagSheet({ </Command> </PopoverContent> </Popover> + {isReadOnly && col.shi && ( + <FormDescription className="text-xs text-gray-500"> + This field is read-only + </FormDescription> + )} <FormMessage /> </FormItem> ); @@ -257,7 +294,7 @@ export function UpdateTagSheet({ // <FormControl> // <Input // type="date" - // readOnly={isTagNumberField} + // readOnly={isReadOnly} // onChange={field.onChange} // value={field.value ?? ""} // /> @@ -270,13 +307,26 @@ export function UpdateTagSheet({ default: return ( <FormItem> - <FormLabel>{col.label}</FormLabel> + <FormLabel className="flex items-center"> + {col.label} + {isReadOnly && ( + <LockIcon className="ml-1 h-3 w-3 text-gray-400" /> + )} + </FormLabel> <FormControl> <Input - readOnly={isTagNumberField} + readOnly={isReadOnly} {...field} + className={cn( + isReadOnly && "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300" + )} /> </FormControl> + {isReadOnly && col.shi && ( + <FormDescription className="text-xs text-gray-500"> + This field is read-only + </FormDescription> + )} <FormMessage /> </FormItem> ); |
