summaryrefslogtreecommitdiff
path: root/components/form-data-plant/sedp-compare-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/form-data-plant/sedp-compare-dialog.tsx')
-rw-r--r--components/form-data-plant/sedp-compare-dialog.tsx618
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