summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/bidding/bidding-info-header.tsx21
-rw-r--r--components/bidding/price-adjustment-dialog.tsx20
-rw-r--r--components/form-data-stat/form-data-stat-table.tsx261
-rw-r--r--components/knox/approval/ApprovalCancel.tsx15
-rw-r--r--components/knox/approval/ApprovalDetail.tsx16
-rw-r--r--components/knox/approval/ApprovalList.tsx16
-rw-r--r--components/pq-input/pq-input-tabs.tsx13
7 files changed, 235 insertions, 127 deletions
diff --git a/components/bidding/bidding-info-header.tsx b/components/bidding/bidding-info-header.tsx
index c140920b..e109a8ca 100644
--- a/components/bidding/bidding-info-header.tsx
+++ b/components/bidding/bidding-info-header.tsx
@@ -1,31 +1,12 @@
import { Bidding } from '@/db/schema/bidding'
import { Building2, Package, User, DollarSign, Calendar } from 'lucide-react'
import { contractTypeLabels, biddingTypeLabels } from '@/db/schema/bidding'
+import { formatDate } from '@/lib/utils'
interface BiddingInfoHeaderProps {
bidding: Bidding
}
-function formatDate(date: Date | string | null | undefined, locale: 'KR' | 'EN' = 'KR'): string {
- if (!date) return ''
-
- const dateObj = typeof date === 'string' ? new Date(date) : date
-
- if (locale === 'KR') {
- return dateObj.toLocaleDateString('ko-KR', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit'
- }).replace(/\./g, '-').replace(/-$/, '')
- }
-
- return dateObj.toLocaleDateString('en-US', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit'
- })
-}
-
export function BiddingInfoHeader({ bidding }: BiddingInfoHeaderProps) {
return (
<div className="bg-white border rounded-lg p-6 mb-6 shadow-sm">
diff --git a/components/bidding/price-adjustment-dialog.tsx b/components/bidding/price-adjustment-dialog.tsx
index b53f9ef1..982d8b90 100644
--- a/components/bidding/price-adjustment-dialog.tsx
+++ b/components/bidding/price-adjustment-dialog.tsx
@@ -10,6 +10,7 @@ import {
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
+import { formatDate } from '@/lib/utils'
interface PriceAdjustmentData {
id: number
@@ -39,15 +40,6 @@ interface PriceAdjustmentDialogProps {
vendorName: string
}
-function formatDate(date: Date | null | undefined): string {
- if (!date) return '-'
- return new Date(date).toLocaleDateString('ko-KR', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- })
-}
-
export function PriceAdjustmentDialog({
open,
onOpenChange,
@@ -135,11 +127,11 @@ export function PriceAdjustmentDialog({
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-500">기준시점</label>
- <p className="text-sm font-medium">{formatDate(data.referenceDate)}</p>
+ <p className="text-sm font-medium">{formatDate(data.referenceDate, "kr")}</p>
</div>
<div>
<label className="text-xs text-gray-500">비교시점</label>
- <p className="text-sm font-medium">{formatDate(data.comparisonDate)}</p>
+ <p className="text-sm font-medium">{formatDate(data.comparisonDate, "kr")}</p>
</div>
</div>
<div>
@@ -170,7 +162,7 @@ export function PriceAdjustmentDialog({
</div>
<div>
<label className="text-xs text-gray-500">조정일</label>
- <p className="text-sm font-medium">{formatDate(data.adjustmentDate)}</p>
+ <p className="text-sm font-medium">{formatDate(data.adjustmentDate, "kr")}</p>
</div>
</div>
<div>
@@ -190,8 +182,8 @@ export function PriceAdjustmentDialog({
{/* 메타 정보 */}
<div className="text-xs text-gray-500 space-y-1">
- <p>작성일: {formatDate(data.createdAt)}</p>
- <p>수정일: {formatDate(data.updatedAt)}</p>
+ <p>작성일: {formatDate(data.createdAt, "kr")}</p>
+ <p>수정일: {formatDate(data.updatedAt, "kr")}</p>
</div>
</div>
</DialogContent>
diff --git a/components/form-data-stat/form-data-stat-table.tsx b/components/form-data-stat/form-data-stat-table.tsx
index fe59785d..a56a4e88 100644
--- a/components/form-data-stat/form-data-stat-table.tsx
+++ b/components/form-data-stat/form-data-stat-table.tsx
@@ -1,10 +1,11 @@
+// components/form-data-stat/form-data-stat-table.tsx
"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 { RefreshCw, Check, ChevronsUpDown } 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";
@@ -12,17 +13,25 @@ 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";
+import { getVendorFormStatus, getProjectsWithContracts } from "@/lib/forms/stat";
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
+import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command";
// 타입 정의
interface VendorFormStatus {
vendorId: number;
vendorName: string;
- formCount: number; // 벤더가 가진 form 개수
- tagCount: number; // 벤더가 가진 tag 개수
- totalFields: number; // 입력해야 하는 총 필드 개수
- completedFields: number; // 입력 완료된 필드 개수
- completionRate: number; // 완료율 (%)
+ formCount: number;
+ tagCount: number;
+ totalFields: number;
+ completedFields: number;
+ completionRate: number;
+}
+
+interface Project {
+ id: number;
+ projectCode: string;
+ projectName: string;
}
interface VendorFormStatusTableProps {
@@ -45,35 +54,164 @@ const getCompletionBadgeVariant = (rate: number): "default" | "secondary" | "des
return "destructive";
};
+// 프로젝트 선택 컴포넌트
+function ProjectSelectorWithContracts({
+ selectedProject,
+ onProjectSelect,
+ projects,
+ isLoading
+}: {
+ selectedProject: Project | null;
+ onProjectSelect: (project: Project | null) => void;
+ projects: Project[];
+ isLoading: boolean;
+}) {
+ const [open, setOpen] = React.useState(false);
+ const [searchTerm, setSearchTerm] = React.useState("");
+
+ // 검색어로 필터링
+ const filteredProjects = React.useMemo(() => {
+ if (!searchTerm.trim()) return projects;
+
+ const lowerSearch = searchTerm.toLowerCase();
+ return projects.filter(
+ project =>
+ project.projectCode.toLowerCase().includes(lowerSearch) ||
+ project.projectName.toLowerCase().includes(lowerSearch)
+ );
+ }, [projects, searchTerm]);
+
+ const handleSelectProject = (project: Project | null) => {
+ onProjectSelect(project);
+ setOpen(false);
+ };
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between"
+ disabled={isLoading}
+ >
+ {selectedProject
+ ? `${selectedProject.projectCode} - ${selectedProject.projectName}`
+ : "프로젝트를 선택하세요..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="프로젝트 코드/이름 검색..."
+ onValueChange={setSearchTerm}
+ />
+ <CommandList className="max-h-[300px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ {isLoading ? (
+ <div className="py-6 text-center text-sm">로딩 중...</div>
+ ) : (
+ <CommandGroup>
+ {/* 전체 옵션 추가 */}
+ <CommandItem
+ value="all"
+ onSelect={() => handleSelectProject(null)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedProject === null ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <span className="font-medium">전체 프로젝트</span>
+ </CommandItem>
+ {/* 프로젝트 목록 */}
+ {filteredProjects.map((project) => (
+ <CommandItem
+ key={project.id}
+ value={`${project.projectCode} ${project.projectName}`}
+ onSelect={() => handleSelectProject(project)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedProject?.id === project.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{project.projectCode}</span>
+ <span className="ml-2 text-gray-500 truncate">- {project.projectName}</span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
export function VendorFormStatusTable({
initialData = [],
}: VendorFormStatusTableProps) {
const [data, setData] = React.useState<VendorFormStatus[]>(initialData);
const [isRefreshing, setIsRefreshing] = React.useState(false);
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null);
+ const [projects, setProjects] = React.useState<Project[]>([]);
+ const [isLoadingProjects, setIsLoadingProjects] = React.useState(false);
+
+ // 프로젝트 목록 로드
+ React.useEffect(() => {
+ const loadProjects = async () => {
+ setIsLoadingProjects(true);
+ try {
+ const projectsWithContracts = await getProjectsWithContracts();
+ setProjects(projectsWithContracts);
+
+ // 프로젝트가 하나만 있으면 자동 선택
+ if (projectsWithContracts.length === 1) {
+ setSelectedProject(projectsWithContracts[0]);
+ }
+ } catch (error) {
+ console.error("프로젝트 목록 로드 오류:", error);
+ toast.error("프로젝트 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoadingProjects(false);
+ }
+ };
+
+ loadProjects();
+ }, []);
+
+ // 프로젝트 변경 시 데이터 로드
+ React.useEffect(() => {
+ handleRefresh();
+ }, [selectedProject]);
// 데이터 새로고침
const handleRefresh = React.useCallback(async () => {
setIsRefreshing(true);
try {
- const result = await getVendorFormStatus();
+ const result = await getVendorFormStatus(selectedProject?.id);
setData(result);
- toast.success("데이터를 새로고침했습니다.");
+ if (selectedProject) {
+ toast.success(`${selectedProject.projectCode} 데이터를 새로고침했습니다.`);
+ } else {
+ toast.success("전체 프로젝트 데이터를 새로고침했습니다.");
+ }
} catch (error) {
console.error("Refresh error:", error);
toast.error("새로고침 중 오류가 발생했습니다.");
} finally {
setIsRefreshing(false);
}
- }, []);
+ }, [selectedProject]);
- // 초기 데이터 로드
- React.useEffect(() => {
- if (initialData.length === 0) {
- handleRefresh();
- }
- }, []);
-
- // 컬럼 정의
+ // 컬럼 정의 (기존과 동일)
const columns: ColumnDef<VendorFormStatus>[] = React.useMemo(() => [
{
accessorKey: "vendorName",
@@ -197,6 +335,39 @@ export function VendorFormStatusTable({
return (
<div className={cn("w-full space-y-4")}>
+ {/* 프로젝트 선택 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>프로젝트 선택</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center gap-4">
+ <div className="flex-1">
+ <ProjectSelectorWithContracts
+ selectedProject={selectedProject}
+ onProjectSelect={setSelectedProject}
+ projects={projects}
+ isLoading={isLoadingProjects}
+ />
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isRefreshing}
+ >
+ <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
+ 새로고침
+ </Button>
+ </div>
+ {selectedProject && (
+ <div className="mt-2 text-sm text-muted-foreground">
+ 선택된 프로젝트: {selectedProject.projectCode} - {selectedProject.projectName}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
{/* 요약 카드 */}
<div className="grid gap-4 md:grid-cols-5">
<Card>
@@ -249,33 +420,39 @@ export function VendorFormStatusTable({
<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>
+ <CardTitle>
+ 벤더별 Form 입력 현황
+ {selectedProject && (
+ <span className="ml-2 text-sm font-normal text-muted-foreground">
+ ({selectedProject.projectCode})
+ </span>
+ )}
+ </CardTitle>
</div>
</CardHeader>
<CardContent>
- <ClientDataTable
- columns={columns}
- data={data}
- advancedFilterFields={advancedFilterFields}
- autoSizeColumns={true}
- compact={true}
- maxHeight="34rem"
- initialColumnPinning={{
- left: ["vendorName"],
- }}
- defaultSorting={[
- { id: "completionRate", desc: false }, // 완료율 낮은 순으로 정렬
- ]}
- />
+ {data.length > 0 ? (
+ <ClientDataTable
+ columns={columns}
+ data={data}
+ advancedFilterFields={advancedFilterFields}
+ autoSizeColumns={true}
+ compact={true}
+ maxHeight="34rem"
+ initialColumnPinning={{
+ left: ["vendorName"],
+ }}
+ defaultSorting={[
+ { id: "completionRate", desc: false }, // 완료율 낮은 순으로 정렬
+ ]}
+ />
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {selectedProject
+ ? "선택한 프로젝트에 대한 데이터가 없습니다."
+ : "데이터가 없습니다."}
+ </div>
+ )}
</CardContent>
</Card>
</div>
diff --git a/components/knox/approval/ApprovalCancel.tsx b/components/knox/approval/ApprovalCancel.tsx
index 62afce94..e3981cb7 100644
--- a/components/knox/approval/ApprovalCancel.tsx
+++ b/components/knox/approval/ApprovalCancel.tsx
@@ -15,6 +15,7 @@ import { Loader2, XCircle, AlertTriangle, CheckCircle } from 'lucide-react';
// API 함수 및 타입
import { cancelApproval, getApprovalDetail } from '@/lib/knox-api/approval/approval';
import type { ApprovalDetailResponse } from '@/lib/knox-api/approval/approval';
+import { formatDate } from '@/lib/utils';
// 상태 코드 텍스트 매핑 (mock util 대체)
const getStatusText = (status: string) => {
@@ -117,18 +118,6 @@ export default function ApprovalCancel({
}
};
- const formatDate = (dateString: string) => {
- if (!dateString || dateString.length < 14) return dateString;
- const year = dateString.substring(0, 4);
- const month = dateString.substring(4, 6);
- const day = dateString.substring(6, 8);
- const hour = dateString.substring(8, 10);
- const minute = dateString.substring(10, 12);
- const second = dateString.substring(12, 14);
-
- return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
- };
-
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case '2': // 완결
@@ -251,7 +240,7 @@ export default function ApprovalCancel({
</div>
<div>
<Label className="text-sm font-medium text-gray-600">상신일시</Label>
- <p className="text-sm mt-1">{formatDate(approvalDetail.sbmDt)}</p>
+ <p className="text-sm mt-1">{formatDate(approvalDetail.sbmDt, "kr")}</p>
</div>
<div>
<Label className="text-sm font-medium text-gray-600">현재 상태</Label>
diff --git a/components/knox/approval/ApprovalDetail.tsx b/components/knox/approval/ApprovalDetail.tsx
index c36137b4..1be58d21 100644
--- a/components/knox/approval/ApprovalDetail.tsx
+++ b/components/knox/approval/ApprovalDetail.tsx
@@ -13,6 +13,7 @@ import { Loader2, Search, FileText, Clock, User, AlertCircle } from 'lucide-reac
// API 함수 및 타입
import { getApprovalDetail, getApprovalContent } from '@/lib/knox-api/approval/approval';
import type { ApprovalDetailResponse, ApprovalContentResponse, ApprovalLine } from '@/lib/knox-api/approval/approval';
+import { formatDate } from '@/lib/utils';
// 상태/역할 텍스트 매핑 (mock util 대체)
const getStatusText = (status: string) => {
@@ -104,19 +105,6 @@ export default function ApprovalDetail({
}
};
- const formatDate = (dateString: string) => {
- if (!dateString || dateString.length < 14) return dateString;
- // YYYYMMDDHHMMSS 형식을 YYYY-MM-DD HH:MM:SS로 변환
- const year = dateString.substring(0, 4);
- const month = dateString.substring(4, 6);
- const day = dateString.substring(6, 8);
- const hour = dateString.substring(8, 10);
- const minute = dateString.substring(10, 12);
- const second = dateString.substring(12, 14);
-
- return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
- };
-
const getSecurityTypeText = (type: string) => {
const typeMap: Record<string, string> = {
'PERSONAL': '개인',
@@ -279,7 +267,7 @@ export default function ApprovalDetail({
<Label className="text-sm font-medium text-gray-600">상신일시</Label>
<p className="text-sm mt-1 flex items-center gap-2">
<Clock className="w-4 h-4" />
- {formatDate(approvalData.detail.sbmDt)}
+ {formatDate(approvalData.detail.sbmDt, "kr")}
</p>
</div>
<div>
diff --git a/components/knox/approval/ApprovalList.tsx b/components/knox/approval/ApprovalList.tsx
index 13a13936..25b9618d 100644
--- a/components/knox/approval/ApprovalList.tsx
+++ b/components/knox/approval/ApprovalList.tsx
@@ -11,6 +11,7 @@ import { Loader2, List, Eye, RefreshCw, AlertCircle } from 'lucide-react';
// API 함수 및 타입
import { getSubmissionList, getApprovalHistory } from '@/lib/knox-api/approval/approval';
import type { SubmissionListResponse, ApprovalHistoryResponse } from '@/lib/knox-api/approval/approval';
+import { formatDate } from '@/lib/utils';
// 상태 텍스트 매핑 (mock util 대체)
const getStatusText = (status: string) => {
@@ -82,17 +83,6 @@ export default function ApprovalList({
}
};
- const formatDate = (dateString: string) => {
- if (!dateString || dateString.length < 14) return dateString;
- const year = dateString.substring(0, 4);
- const month = dateString.substring(4, 6);
- const day = dateString.substring(6, 8);
- const hour = dateString.substring(8, 10);
- const minute = dateString.substring(10, 12);
-
- return `${year}-${month}-${day} ${hour}:${minute}`;
- };
-
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case '2': // 완결
@@ -247,7 +237,7 @@ export default function ApprovalList({
{item.subject}
</TableCell>
<TableCell>
- {formatDate(item.sbmDt)}
+ {formatDate(item.sbmDt, "kr")}
</TableCell>
<TableCell>
<Badge variant={getStatusBadgeVariant(item.status)}>
@@ -282,7 +272,7 @@ export default function ApprovalList({
{type === 'history' && (
<>
<TableCell>
- {item.actionDt ? formatDate(item.actionDt) : '-'}
+ {item.actionDt ? formatDate(item.actionDt, "kr") : '-'}
</TableCell>
<TableCell>
{item.userId || '-'}
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx
index 7ae6d16a..534e1a05 100644
--- a/components/pq-input/pq-input-tabs.tsx
+++ b/components/pq-input/pq-input-tabs.tsx
@@ -77,6 +77,7 @@ import {
ProjectPQ,
} from "@/lib/pq/service"
import { PQGroupData } from "@/lib/pq/service"
+import { formatDate } from "@/lib/utils"
// ----------------------------------------------------------------------
// 1) Define client-side file shapes
@@ -573,7 +574,7 @@ export function PQInputTabs({
{projectData.submittedAt && (
<div className="col-span-1 md:col-span-2">
<p className="text-sm font-medium text-muted-foreground">제출일</p>
- <p>{formatDate(projectData.submittedAt)}</p>
+ <p>{formatDate(projectData.submittedAt, "kr")}</p>
</div>
)}
</div>
@@ -604,16 +605,6 @@ export function PQInputTabs({
}
};
- // 날짜 형식화 함수
- const formatDate = (date: Date) => {
- if (!date) return "-";
- return new Date(date).toLocaleDateString("ko-KR", {
- year: "numeric",
- month: "long",
- day: "numeric",
- });
- };
-
// ----------------------------------------------------------------------
// H) Render
// ----------------------------------------------------------------------