diff options
| -rw-r--r-- | app/[lng]/admin/edp-progress-debug/page.tsx | 210 | ||||
| -rw-r--r-- | app/[lng]/admin/edp-progress/page.tsx | 135 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/edp-progress/page.tsx | 21 | ||||
| -rw-r--r-- | components/form-data-stat/form-data-stat-table.tsx | 276 | ||||
| -rw-r--r-- | lib/forms/stat.ts | 162 |
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 입력 현황 조회 중 오류가 발생했습니다.') + } +} |
