summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-05 01:16:42 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-05 01:16:42 +0000
commited52f5f887fa79addf0d9686cc440a626d22f6bf (patch)
treef086a2cde7ff2be0f72d7b403f296f3270e0f45c
parentb6c9ac31358206ad291583d4b045cc5d83ca3987 (diff)
(대표님, 임수민) 벤더별 EDP 데이터 입력 진행률 페이지 개선
-rw-r--r--app/[lng]/admin/edp-progress-debug/page.tsx210
-rw-r--r--app/[lng]/admin/edp-progress/page.tsx135
-rw-r--r--app/[lng]/evcp/(evcp)/edp-progress/page.tsx21
-rw-r--r--components/form-data-stat/form-data-stat-table.tsx276
-rw-r--r--lib/forms/stat.ts162
5 files changed, 441 insertions, 363 deletions
diff --git a/app/[lng]/admin/edp-progress-debug/page.tsx b/app/[lng]/admin/edp-progress-debug/page.tsx
deleted file mode 100644
index ebaa07a2..00000000
--- a/app/[lng]/admin/edp-progress-debug/page.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-"use client";
-
-import React from 'react';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Badge } from '@/components/ui/badge';
-import { ScrollArea } from '@/components/ui/scroll-area';
-import { debugVendorFieldCalculation } from '@/lib/forms/vendor-completion-stats';
-import { Loader, Search, FileText, Tag, CheckCircle, XCircle } from 'lucide-react';
-import { toast } from 'sonner';
-
-export default function DebugVendorFieldsPage() {
- const [loading, setLoading] = React.useState(false);
- const [vendorId, setVendorId] = React.useState('1');
- const [debugData, setDebugData] = React.useState<any>(null);
-
- const handleDebug = async () => {
- setLoading(true);
- setDebugData(null);
-
- try {
- const result = await debugVendorFieldCalculation(Number(vendorId));
- setDebugData(result);
-
- if (result) {
- toast.success(`${result.vendorName}의 필드 계산 디버그 완료`);
- } else {
- toast.warning('벤더 데이터가 없습니다');
- }
- } catch (error) {
- console.error('Error debugging vendor fields:', error);
- toast.error(`디버그 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
- } finally {
- setLoading(false);
- }
- };
-
- const renderFieldDetails = (fieldDetails: any[]) => (
- <div className="space-y-1">
- {fieldDetails.map((field, index) => (
- <div key={index} className="flex items-center gap-2 text-xs">
- <span className="font-mono bg-muted px-1 rounded">{field.fieldKey}</span>
- <span className="text-muted-foreground">=</span>
- <span className="font-mono">{String(field.fieldValue ?? 'null')}</span>
- {field.isEmpty ? (
- <XCircle className="h-3 w-3 text-red-500" />
- ) : (
- <CheckCircle className="h-3 w-3 text-green-500" />
- )}
- </div>
- ))}
- </div>
- );
-
- return (
- <div className="container mx-auto p-6 space-y-6">
- <div className="flex items-center gap-2 mb-6">
- <Search className="h-6 w-6" />
- <h1 className="text-3xl font-bold">벤더 필드 계산 디버그</h1>
- </div>
-
- {/* Input */}
- <Card>
- <CardHeader>
- <CardTitle>벤더 ID 입력</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="flex gap-4">
- <div className="space-y-2">
- <Label htmlFor="vendorId">Vendor ID</Label>
- <Input
- id="vendorId"
- value={vendorId}
- onChange={(e) => setVendorId(e.target.value)}
- placeholder="1"
- type="number"
- />
- </div>
- <div className="flex items-end">
- <Button onClick={handleDebug} disabled={loading}>
- {loading ? <Loader className="h-4 w-4 animate-spin mr-2" /> : <Search className="h-4 w-4 mr-2" />}
- 디버그 실행
- </Button>
- </div>
- </div>
- </CardContent>
- </Card>
-
- {/* Results */}
- {debugData && (
- <div className="space-y-4">
- {/* Summary */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- {debugData.vendorName} - 전체 요약
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
- <div className="text-center">
- <div className="text-2xl font-bold text-blue-600">
- {debugData.debugInfo.grandTotal.totalRequiredFields}
- </div>
- <p className="text-sm text-muted-foreground">전체 필드</p>
- </div>
- <div className="text-center">
- <div className="text-2xl font-bold text-green-600">
- {debugData.debugInfo.grandTotal.totalFilledFields}
- </div>
- <p className="text-sm text-muted-foreground">입력 필드</p>
- </div>
- <div className="text-center">
- <div className="text-2xl font-bold text-red-600">
- {debugData.debugInfo.grandTotal.totalEmptyFields}
- </div>
- <p className="text-sm text-muted-foreground">빈 필드</p>
- </div>
- <div className="text-center">
- <div className="text-2xl font-bold text-purple-600">
- {debugData.debugInfo.grandTotal.completionPercentage}%
- </div>
- <p className="text-sm text-muted-foreground">완성도</p>
- </div>
- </div>
- </CardContent>
- </Card>
-
- {/* Detailed Breakdown */}
- <Card>
- <CardHeader>
- <CardTitle>상세 분석</CardTitle>
- </CardHeader>
- <CardContent>
- <ScrollArea className="h-96">
- <div className="space-y-4">
- {debugData.debugInfo.contracts.map((contract: any, contractIndex: number) => (
- <div key={contractIndex} className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Tag className="h-4 w-4" />
- <span className="font-semibold">
- 계약 {contract.contractId} - {contract.projectName}
- </span>
- <Badge variant="outline">
- 전체: {contract.totalRequiredFields} | 입력: {contract.totalFilledFields}
- </Badge>
- </div>
-
- <div className="space-y-3 ml-4">
- {contract.forms.map((form: any, formIndex: number) => (
- <div key={formIndex} className="border-l-2 border-muted pl-4">
- <div className="flex items-center gap-2 mb-2">
- <FileText className="h-3 w-3" />
- <span className="font-medium">{form.formName} ({form.formCode})</span>
- <Badge variant="secondary" className="text-xs">
- 전체: {form.totalRequiredFields} | 입력: {form.totalFilledFields}
- </Badge>
- </div>
-
- <div className="space-y-2 ml-4">
- {form.tags.map((tag: any, tagIndex: number) => (
- <div key={tagIndex} className="bg-muted/50 rounded p-2">
- <div className="flex items-center gap-2 mb-1">
- <Tag className="h-3 w-3" />
- <span className="font-mono text-sm">{tag.tagNo}</span>
- <Badge variant="outline" className="text-xs">
- 전체: {tag.requiredFieldsCount} | 입력: {tag.filledFieldsCount}
- </Badge>
- </div>
-
- <div className="ml-4">
- <div className="text-xs text-muted-foreground mb-1">
- 편집 가능한 필드: {tag.editableFields.join(', ')}
- </div>
- {renderFieldDetails(tag.fieldDetails)}
- </div>
- </div>
- ))}
- </div>
- </div>
- ))}
- </div>
- </div>
- ))}
- </div>
- </ScrollArea>
- </CardContent>
- </Card>
-
- {/* Raw Data */}
- <Card>
- <CardHeader>
- <CardTitle>원시 데이터 (JSON)</CardTitle>
- </CardHeader>
- <CardContent>
- <ScrollArea className="h-64">
- <pre className="text-xs bg-muted p-4 rounded overflow-auto">
- {JSON.stringify(debugData, null, 2)}
- </pre>
- </ScrollArea>
- </CardContent>
- </Card>
- </div>
- )}
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/admin/edp-progress/page.tsx b/app/[lng]/admin/edp-progress/page.tsx
deleted file mode 100644
index c42a1db7..00000000
--- a/app/[lng]/admin/edp-progress/page.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-"use client";
-
-import React from 'react';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Badge } from '@/components/ui/badge';
-import { ScrollArea } from '@/components/ui/scroll-area';
-import { getAllVendorsContractsCompletionSummary } from '@/lib/forms/vendor-completion-stats';
-import { Loader, Users, RefreshCw } from 'lucide-react';
-import { toast } from 'sonner';
-
-interface VendorProgress {
- vendorId: number;
- vendorName: string;
- totalForms: number;
- tagCount: number;
- totalRequiredFields: number;
- totalFilledFields: number;
- completionPercentage: number;
-}
-
-export default function EDPProgressTestPage() {
- const [loading, setLoading] = React.useState(false);
- const [vendorProgress, setVendorProgress] = React.useState<VendorProgress[]>([]);
-
- const loadVendorProgress = async () => {
- setLoading(true);
-
- try {
- const result = await getAllVendorsContractsCompletionSummary();
-
- if (result && result.vendors) {
- const progressData: VendorProgress[] = result.vendors.map(vendor => ({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- totalForms: vendor.totalForms,
- tagCount: vendor.totalTags,
- totalRequiredFields: vendor.totalRequiredFields,
- totalFilledFields: vendor.totalFilledFields,
- completionPercentage: vendor.overallCompletionPercentage
- }));
-
- setVendorProgress(progressData);
- toast.success(`${progressData.length}개 벤더의 진척도를 불러왔습니다`);
- } else {
- toast.warning('벤더 데이터가 없습니다');
- }
- } catch (error) {
- console.error('Error loading vendor progress:', error);
- toast.error(`벤더 진척도 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
- } finally {
- setLoading(false);
- }
- };
-
- React.useEffect(() => {
- loadVendorProgress();
- }, []);
-
- return (
- <div className="container mx-auto p-6 space-y-6">
- <div className="flex items-center justify-between mb-6">
- <div className="flex items-center gap-2">
- <Users className="h-6 w-6" />
- <h1 className="text-3xl font-bold">벤더 진척도 현황</h1>
- </div>
- <Button
- onClick={loadVendorProgress}
- disabled={loading}
- className="flex items-center gap-2"
- >
- {loading ? <Loader className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
- 새로고침
- </Button>
- </div>
-
- {/* Vendor Progress List */}
- <Card>
- <CardHeader>
- <CardTitle>벤더별 작업 진척도</CardTitle>
- </CardHeader>
- <CardContent>
- {loading ? (
- <div className="flex items-center justify-center py-8">
- <Loader className="h-6 w-6 animate-spin mr-2" />
- <span>벤더 진척도를 불러오는 중...</span>
- </div>
- ) : vendorProgress.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- 벤더 데이터가 없습니다.
- </div>
- ) : (
- <div className="space-y-2">
- {/* Header */}
- <div className="grid grid-cols-6 gap-4 p-3 bg-muted rounded-lg font-semibold text-sm">
- <div>벤더명</div>
- <div className="text-center">폼 개수</div>
- <div className="text-center">태그 개수</div>
- <div className="text-center">전체 필드</div>
- <div className="text-center">입력 필드</div>
- <div className="text-center">완성도</div>
- </div>
-
- {/* Vendor Rows */}
- <ScrollArea className="h-96">
- <div className="space-y-1">
- {vendorProgress.map((vendor) => (
- <div key={vendor.vendorId} className="grid grid-cols-6 gap-4 p-3 border rounded-lg hover:bg-muted/50">
- <div className="font-medium">{vendor.vendorName}</div>
- <div className="text-center">{vendor.totalForms}</div>
- <div className="text-center">{vendor.tagCount}</div>
- <div className="text-center">{vendor.totalRequiredFields}</div>
- <div className="text-center">{vendor.totalFilledFields}</div>
- <div className="text-center">
- <Badge
- variant={
- vendor.completionPercentage >= 80 ? "default" :
- vendor.completionPercentage >= 50 ? "secondary" :
- "destructive"
- }
- >
- {vendor.completionPercentage}%
- </Badge>
- </div>
- </div>
- ))}
- </div>
- </ScrollArea>
- </div>
- )}
- </CardContent>
- </Card>
- </div>
- );
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
index 12e14b98..7edc52c9 100644
--- a/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
+++ b/app/[lng]/evcp/(evcp)/edp-progress/page.tsx
@@ -1,27 +1,12 @@
import * as React from "react"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { EDPProgressTable } from "@/lib/edp-progress/table/edp-progress-table"
-import { getEDPProgressLists } from "@/lib/edp-progress/service"
import { Shell } from "@/components/shell"
import { InformationButton } from "@/components/information/information-button"
-import { searchParamsCache } from "@/lib/edp-progress/validations"
+import { VendorFormStatusTable } from "@/components/form-data-stat/form-data-stat-table"
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
- const promises = Promise.all([
- getEDPProgressLists({ filters: validFilters, sort: search.sort, search: search.search, joinOperator: search.joinOperator as any }),
- ])
+export default async function IndexPage() {
return (
<Shell className="gap-2">
@@ -43,7 +28,7 @@ export default async function IndexPage(props: IndexPageProps) {
/>
}
>
- <EDPProgressTable promises={promises} />
+ <VendorFormStatusTable />
</React.Suspense>
</Shell>
)
diff --git a/components/form-data-stat/form-data-stat-table.tsx b/components/form-data-stat/form-data-stat-table.tsx
new file mode 100644
index 00000000..624e85fb
--- /dev/null
+++ b/components/form-data-stat/form-data-stat-table.tsx
@@ -0,0 +1,276 @@
+"use client";
+
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { RefreshCw } from "lucide-react";
+import { type ColumnDef } from "@tanstack/react-table";
+import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header";
+import { ClientDataTable } from "@/components/client-data-table/data-table";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
+import type { DataTableAdvancedFilterField } from "@/types/table";
+import { Progress } from "@/components/ui/progress";
+import { getVendorFormStatus } from "@/lib/forms/stat";
+
+// 타입 정의
+interface VendorFormStatus {
+ vendorId: number;
+ vendorName: string;
+ formCount: number; // 벤더가 가진 form 개수
+ tagCount: number; // 벤더가 가진 tag 개수
+ totalFields: number; // 입력해야 하는 총 필드 개수
+ completedFields: number; // 입력 완료된 필드 개수
+ completionRate: number; // 완료율 (%)
+}
+
+interface VendorFormStatusTableProps {
+ initialData?: VendorFormStatus[];
+}
+
+// 완료율에 따른 색상 반환
+const getCompletionColor = (rate: number) => {
+ if (rate >= 80) return "text-green-600";
+ if (rate >= 50) return "text-yellow-600";
+ if (rate >= 20) return "text-orange-600";
+ return "text-red-600";
+};
+
+// 완료율에 따른 Badge variant 반환
+const getCompletionBadgeVariant = (rate: number): "default" | "secondary" | "destructive" | "outline" => {
+ if (rate >= 80) return "default";
+ if (rate >= 50) return "secondary";
+ if (rate >= 20) return "outline";
+ return "destructive";
+};
+
+export function VendorFormStatusTable({
+ initialData = [],
+}: VendorFormStatusTableProps) {
+ const [data, setData] = React.useState<VendorFormStatus[]>(initialData);
+ const [isRefreshing, setIsRefreshing] = React.useState(false);
+
+ // 데이터 새로고침
+ const handleRefresh = React.useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ const result = await getVendorFormStatus();
+ setData(result);
+ toast.success("데이터를 새로고침했습니다.");
+ } catch (error) {
+ console.error("Refresh error:", error);
+ toast.error("새로고침 중 오류가 발생했습니다.");
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, []);
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ if (initialData.length === 0) {
+ handleRefresh();
+ }
+ }, []);
+
+ // 컬럼 정의
+ const columns: ColumnDef<VendorFormStatus>[] = React.useMemo(() => [
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="벤더명" />,
+ cell: ({ row }) => (
+ <div className="font-medium">{row.original.vendorName}</div>
+ ),
+ size: 200,
+ enablePinning: true,
+ },
+ {
+ accessorKey: "formCount",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Form 개수" />,
+ cell: ({ row }) => (
+ <div className="text-center">
+ <Badge variant="outline">{row.original.formCount}</Badge>
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "tagCount",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Tag 개수" />,
+ cell: ({ row }) => (
+ <div className="text-center">
+ <Badge variant="outline">{row.original.tagCount}</Badge>
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "totalFields",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="전체 필드" />,
+ cell: ({ row }) => (
+ <div className="text-center font-mono">
+ {row.original.totalFields.toLocaleString()}
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "completedFields",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="완료 필드" />,
+ cell: ({ row }) => (
+ <div className="text-center font-mono">
+ {row.original.completedFields.toLocaleString()}
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "completionRate",
+ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="완료율" />,
+ cell: ({ row }) => {
+ const rate = row.original.completionRate;
+ return (
+ <div className="flex items-center gap-2">
+ <Progress
+ value={rate}
+ className="w-[80px]"
+ />
+ <Badge
+ variant={getCompletionBadgeVariant(rate)}
+ className={cn("min-w-[60px] justify-center", getCompletionColor(rate))}
+ >
+ {rate.toFixed(1)}%
+ </Badge>
+ </div>
+ );
+ },
+ size: 180,
+ },
+ {
+ id: "progress",
+ header: "진행 상태",
+ cell: ({ row }) => {
+ const { completedFields, totalFields } = row.original;
+ return (
+ <div className="text-sm text-muted-foreground">
+ {completedFields} / {totalFields}
+ </div>
+ );
+ },
+ size: 120,
+ },
+ ], []);
+
+ // 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorFormStatus>[] = [
+ { id: "vendorName", label: "벤더명", type: "text" },
+ { id: "formCount", label: "Form 개수", type: "number" },
+ { id: "tagCount", label: "Tag 개수", type: "number" },
+ { id: "completionRate", label: "완료율", type: "number" },
+ ];
+
+ // 요약 통계
+ const summaryStats = React.useMemo(() => {
+ const totalVendors = data.length;
+ const totalForms = data.reduce((sum, v) => sum + v.formCount, 0);
+ const totalTags = data.reduce((sum, v) => sum + v.tagCount, 0);
+ const avgCompletionRate = data.length > 0
+ ? data.reduce((sum, v) => sum + v.completionRate, 0) / data.length
+ : 0;
+ const fullCompletedVendors = data.filter(v => v.completionRate === 100).length;
+
+ return {
+ totalVendors,
+ totalForms,
+ totalTags,
+ avgCompletionRate,
+ fullCompletedVendors,
+ };
+ }, [data]);
+
+ return (
+ <div className={cn("w-full space-y-4")}>
+ {/* 요약 카드 */}
+ <div className="grid gap-4 md:grid-cols-5">
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">전체 벤더</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{summaryStats.totalVendors}</div>
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">전체 Form</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{summaryStats.totalForms.toLocaleString()}</div>
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">전체 Tag</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{summaryStats.totalTags.toLocaleString()}</div>
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">평균 완료율</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className={cn("text-2xl font-bold", getCompletionColor(summaryStats.avgCompletionRate))}>
+ {summaryStats.avgCompletionRate.toFixed(1)}%
+ </div>
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader className="pb-2">
+ <CardTitle className="text-sm font-medium">완료 벤더</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ {summaryStats.fullCompletedVendors} / {summaryStats.totalVendors}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 데이터 테이블 */}
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle>벤더별 Form 입력 현황</CardTitle>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isRefreshing}
+ >
+ <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
+ 새로고침
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent>
+ <ClientDataTable
+ columns={columns}
+ data={data}
+ advancedFilterFields={advancedFilterFields}
+ autoSizeColumns={true}
+ compact={true}
+ maxHeight="34rem"
+ initialColumnPinning={{
+ left: ["vendorName"],
+ }}
+ defaultSorting={[
+ { id: "completionRate", desc: false }, // 완료율 낮은 순으로 정렬
+ ]}
+ />
+ </CardContent>
+ </Card>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/forms/stat.ts b/lib/forms/stat.ts
new file mode 100644
index 00000000..fbcc6f46
--- /dev/null
+++ b/lib/forms/stat.ts
@@ -0,0 +1,162 @@
+"use server"
+
+import db from "@/db/db"
+import { vendors, contracts, contractItems, forms, formEntries, formMetas, tags, tagClasses, tagClassAttributes } from "@/db/schema"
+import { eq, and } from "drizzle-orm"
+import { getEditableFieldsByTag } from "./services"
+
+interface VendorFormStatus {
+ vendorId: number
+ vendorName: string
+ formCount: number // 벤더가 가진 form 개수
+ tagCount: number // 벤더가 가진 tag 개수
+ totalFields: number // 입력해야 하는 총 필드 개수
+ completedFields: number // 입력 완료된 필드 개수
+ completionRate: number // 완료율 (%)
+}
+
+
+export async function getVendorFormStatus(): Promise<VendorFormStatus[]> {
+ try {
+ // 1. 모든 벤더 조회
+ const vendorList = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName
+ })
+ .from(vendors)
+
+ 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. 벤더별 계약 조회
+ const vendorContracts = await db
+ .selectDistinct({
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .innerJoin(contracts, eq(contracts.vendorId, vendors.id))
+
+ 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.id,
+ 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 입력 현황 조회 중 오류가 발생했습니다.')
+ }
+}