diff options
Diffstat (limited to 'components/form-data-plant/sedp-compare-dialog.tsx')
| -rw-r--r-- | components/form-data-plant/sedp-compare-dialog.tsx | 618 |
1 files changed, 618 insertions, 0 deletions
diff --git a/components/form-data-plant/sedp-compare-dialog.tsx b/components/form-data-plant/sedp-compare-dialog.tsx new file mode 100644 index 00000000..b481b4f8 --- /dev/null +++ b/components/form-data-plant/sedp-compare-dialog.tsx @@ -0,0 +1,618 @@ +import * as React from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +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 { Input } from "@/components/ui/input"; +import { Loader, RefreshCw, AlertCircle, CheckCircle, Info, EyeOff, ChevronDown, ChevronRight, Search } 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"; +import { Card, CardContent } from "@/components/ui/card"; +import { useTranslation } from "@/i18n/client" +import { useParams } from "next/navigation" +import { fetchTagDataFromSEDP } from "@/lib/forms-plant/sedp-actions"; + +interface SEDPCompareDialogProps { + isOpen: boolean; + onClose: () => void; + tableData: unknown[]; + columnsJSON: DataTableColumnJSON[]; + projectCode: string; + formCode: string; + projectType:string; + packageCode:string; +} + +interface ComparisonResult { + tagNo: string; + tagDesc: string; + isMatching: boolean; + attributes: { + key: string; + label: string; + localValue: unknown; + sedpValue: unknown; + isMatching: boolean; + uom?: string; + }[]; +} + +// Component for formatting display value with UOM +const DisplayValue = ({ value, uom, isSedp = false }: { value: unknown; uom?: string; isSedp?: boolean }) => { + if (value === "" || value === null || value === undefined) { + return <span className="text-muted-foreground italic">(empty)</span>; + } + + // SEDP 값은 UOM을 표시하지 않음 (이미 포함되어 있다고 가정) + if (isSedp) { + return <span>{value}</span>; + } + + // 로컬 값은 UOM과 함께 표시 + return ( + <span> + {value} + {uom && <span className="text-xs text-muted-foreground ml-1">{uom}</span>} + </span> + ); +}; + + +export function SEDPCompareDialog({ + isOpen, + onClose, + tableData, + columnsJSON, + projectCode, + formCode, + projectType, + packageCode +}: SEDPCompareDialogProps) { + + const params = useParams() || {} + const lng = params.lng ? String(params.lng) : "ko" + const { t } = useTranslation(lng, "engineering") + + // 범례 컴포넌트 + const ColorLegend = () => { + return ( + <div className="flex items-center gap-4 text-sm p-2 bg-muted/20 rounded"> + <div className="flex items-center gap-1.5"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">{t("labels.legend")}:</span> + </div> + <div className="flex items-center gap-3"> + <div className="flex items-center gap-1.5"> + <div className="h-3 w-3 rounded-full bg-red-500"></div> + <span className="line-through text-red-500">{t("labels.localValue")}</span> + </div> + <div className="flex items-center gap-1.5"> + <div className="h-3 w-3 rounded-full bg-green-500"></div> + <span className="text-green-500">{t("labels.sedpValue")}</span> + </div> + </div> + </div> + ); + }; + + // 확장 가능한 차이점 표시 컴포넌트 + const DifferencesCard = ({ + attributes, + columnLabelMap, + showOnlyDifferences + }: { + attributes: ComparisonResult['attributes']; + columnLabelMap: Record<string, string>; + showOnlyDifferences: boolean; + }) => { + const attributesToShow = showOnlyDifferences + ? attributes.filter(attr => !attr.isMatching) + : attributes; + + if (attributesToShow.length === 0) { + return ( + <div className="text-center text-muted-foreground py-4"> + {t("messages.allAttributesMatch")} + </div> + ); + } + + return ( + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4"> + {attributesToShow.map((attr) => ( + <Card key={attr.key} className={`${!attr.isMatching ? 'border-red-200' : 'border-green-200'}`}> + <CardContent className="p-3"> + <div className="font-medium text-sm mb-2 truncate" title={attr.label}> + {attr.label} + {attr.uom && <span className="text-xs text-muted-foreground ml-1">({attr.uom})</span>} + </div> + {attr.isMatching ? ( + <div className="text-sm"> + <DisplayValue value={attr.localValue} uom={attr.uom} /> + </div> + ) : ( + <div className="space-y-2"> + <div className="flex items-center gap-2 text-sm"> + <span className="text-xs text-muted-foreground min-w-[35px]">로컬:</span> + <span className="line-through text-red-500 flex-1"> + <DisplayValue value={attr.localValue} uom={attr.uom} isSedp={false} /> + </span> + </div> + <div className="flex items-center gap-2 text-sm"> + <span className="text-xs text-muted-foreground min-w-[35px]">SEDP:</span> + <span className="text-green-500 flex-1"> + <DisplayValue value={attr.sedpValue} uom={attr.uom} isSedp={true} /> + </span> + </div> + </div> + )} + </CardContent> + </Card> + ))} + </div> + ); + }; + + + const [isLoading, setIsLoading] = React.useState(false); + 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); + const [searchTerm, setSearchTerm] = React.useState(""); + const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()); + + // 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 and search results + const filteredResults = React.useMemo(() => { + let results = comparisonResults; + + // Filter by tab + switch (activeTab) { + case "matching": + results = results.filter(r => r.isMatching); + break; + case "differences": + results = results.filter(r => !r.isMatching); + break; + case "all": + default: + break; + } + + // Apply search filter + if (searchTerm.trim()) { + const search = searchTerm.toLowerCase(); + results = results.filter(r => + r.tagNo.toLowerCase().includes(search) || + r.tagDesc.toLowerCase().includes(search) + ); + } + + return results; + }, [comparisonResults, activeTab, searchTerm]); + + // Toggle row expansion + const toggleRowExpansion = (tagNo: string) => { + const newExpanded = new Set(expandedRows); + if (newExpanded.has(tagNo)) { + newExpanded.delete(tagNo); + } else { + newExpanded.add(tagNo); + } + setExpandedRows(newExpanded); + }; + + // Auto-expand rows with differences when switching to differences tab + React.useEffect(() => { + if (activeTab === "differences") { + const newExpanded = new Set<string>(); + filteredResults.filter(r => !r.isMatching).forEach(r => { + newExpanded.add(r.tagNo); + }); + setExpandedRows(newExpanded); + } + }, [activeTab, filteredResults]); + + const fetchAndCompareData = React.useCallback(async () => { + if (!projectCode || !formCode) { + toast.error("Project code or form code is missing"); + return; + } + + 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(); + + const packageCodeAttId = projectType === "ship" ? "CM3003" : "ME5074"; + + + const tagEntries = sedpTagEntries.filter(entry => { + if (Array.isArray(entry.ATTRIBUTES)) { + const packageCodeAttr = entry.ATTRIBUTES.find(attr => attr.ATT_ID === packageCodeAttId); + if (packageCodeAttr && packageCodeAttr.VALUE === packageCode) { + return true; + } + } + return false; + }); + + + tagEntries.forEach((entry: Record<string, unknown>) => { + 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: Record<string, unknown>) => { + attributesMap.set(attr.ATT_ID, attr.VALUE); + }); + } + + sedpTagMap.set(tagNo, { + tagDesc: entry.TAG_DESC, + attributes: attributesMap + }); + }); + + // 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" && col.key !== "status"&& col.key !== "CLS_ID") + .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: 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 && missingCount === 0) { + toast.success(`All ${results.length} tags match with SEDP data`); + } 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'}`); + } finally { + setIsLoading(false); + } + }, [projectCode, formCode, tableData, columnsJSON, columnLabelMap, columnUomMap]); + + // Fetch data when dialog opens + React.useEffect(() => { + if (isOpen) { + fetchAndCompareData(); + } + }, [isOpen, fetchAndCompareData]); + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle className="mb-2">{t("dialogs.sedpDataComparison")}</DialogTitle> + <div className="flex items-center justify-between gap-2 pr-8"> + <div className="flex items-center gap-4"> + <div className="flex items-center gap-2"> + <Switch + checked={showOnlyDifferences} + onCheckedChange={setShowOnlyDifferences} + id="show-differences" + /> + <label htmlFor="show-differences" className="text-sm cursor-pointer"> + {t("switches.showOnlyDifferences")} + </label> + </div> + + {/* 검색 입력 */} + <div className="relative"> + <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> + <Input + placeholder={t("placeholders.searchTagOrDesc")} + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + className="pl-8 w-64" + /> + </div> + </div> + + <div className="flex items-center gap-2"> + <Badge variant={matchingTags === totalTags && totalMissingTags === 0 ? "default" : "destructive"}> + {matchingTags} / {totalTags} 일치 {totalMissingTags > 0 ? `(${totalMissingTags} 누락)` : ''} + </Badge> + <Button + variant="outline" + size="sm" + onClick={fetchAndCompareData} + disabled={isLoading} + > + {isLoading ? ( + <Loader className="h-4 w-4 animate-spin" /> + ) : ( + <RefreshCw className="h-4 w-4" /> + )} + <span className="ml-2">{t("buttons.refresh")}</span> + </Button> + </div> + </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">{t("tabs.allTags")} ({totalTags})</TabsTrigger> + <TabsTrigger value="differences">{t("tabs.differences")} ({nonMatchingTags})</TabsTrigger> + <TabsTrigger value="matching">{t("tabs.matching")} ({matchingTags})</TabsTrigger> + <TabsTrigger value="missing" className={totalMissingTags > 0 ? "text-red-500" : ""}> + {t("tabs.missingTags")} ({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>{t("messages.dataComparing")}</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">{t("sections.localOnlyTags")} ({missingTags.localOnly.length})</h3> + <Table> + <TableHeader className="sticky top-0 bg-background z-10"> + <TableRow> + <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead> + <TableHead>{t("labels.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">{t("sections.sedpOnlyTags")} ({missingTags.sedpOnly.length})</h3> + <Table> + <TableHeader className="sticky top-0 bg-background z-10"> + <TableRow> + <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead> + <TableHead>{t("labels.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"> + {t("messages.allTagsExistInBothSystems")} + </div> + )} + </div> + ) : filteredResults.length > 0 ? ( + // 개선된 확장 가능한 테이블 구조 + <div className="border rounded-md"> + <Table> + <TableHeader className="sticky top-0 bg-muted/50 z-10"> + <TableRow> + <TableHead className="w-12"></TableHead> + <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead> + <TableHead className="w-[250px]">{t("labels.description")}</TableHead> + <TableHead className="w-[120px]">{t("labels.status")}</TableHead> + <TableHead>차이점 개수</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {filteredResults.map((result) => ( + <React.Fragment key={result.tagNo}> + {/* 메인 행 */} + <TableRow + className={`hover:bg-muted/20 cursor-pointer ${!result.isMatching ? "bg-muted/10" : ""}`} + onClick={() => toggleRowExpansion(result.tagNo)} + > + <TableCell> + {result.attributes.some(attr => !attr.isMatching) ? ( + expandedRows.has(result.tagNo) ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + ) + ) : null} + </TableCell> + <TableCell className="font-medium"> + {result.tagNo} + </TableCell> + <TableCell title={result.tagDesc}> + <div className="truncate"> + {result.tagDesc} + </div> + </TableCell> + <TableCell> + {result.isMatching ? ( + <Badge variant="default" className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3" /> + <span>{t("labels.matching")}</span> + </Badge> + ) : ( + <Badge variant="destructive" className="flex items-center gap-1"> + <AlertCircle className="h-3 w-3" /> + <span>{t("labels.different")}</span> + </Badge> + )} + </TableCell> + <TableCell> + {!result.isMatching && ( + <span className="text-sm text-muted-foreground"> + {result.attributes.filter(attr => !attr.isMatching).length}{t("sections.differenceCount")} + </span> + )} + </TableCell> + </TableRow> + + {/* 확장된 차이점 표시 행 */} + {expandedRows.has(result.tagNo) && ( + <TableRow> + <TableCell colSpan={5} className="p-0 bg-muted/5"> + <DifferencesCard + attributes={result.attributes} + columnLabelMap={columnLabelMap} + showOnlyDifferences={showOnlyDifferences} + /> + </TableCell> + </TableRow> + )} + </React.Fragment> + ))} + </TableBody> + </Table> + </div> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + {searchTerm ? t("messages.noSearchResults") : t("messages.noMatchingTags")} + </div> + )} + </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 && totalMissingTags === 0)} + /> + <Button onClick={onClose}>{t("buttons.close")}</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
