feat(WEB): 입찰/계약/주문관리 기능 추가 및 견적 상세 리팩토링

- 입찰관리: 목록/상세/수정 페이지 및 목업 데이터
- 계약관리: 목록/상세/수정 페이지 구현
- 주문관리: 수주/발주 목록 및 상세 페이지 구현
- 견적 상세 폼: 섹션별 분리 및 hooks/utils 리팩토링
- 품목관리, 카테고리관리, 단가관리 기능 추가
- 현장설명회/협력업체 폼 개선
- 프린트 유틸리티 공통화 (print-utils.ts)
- 문서 모달 공통 컴포넌트 정리
- IntegratedListTemplateV2, StatCards 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-05 18:59:04 +09:00
parent 4b1a3abf05
commit 386cd30bc0
145 changed files with 25782 additions and 254 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Building2, Plus, X, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -125,6 +125,16 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
// 새 메모 입력
const [newMemo, setNewMemo] = useState('');
// 상세/수정 모드에서 로고 목데이터 초기화
useEffect(() => {
if (initialData && !formData.logoUrl) {
setFormData(prev => ({
...prev,
logoUrl: 'https://placehold.co/750x250/3b82f6/white?text=Vendor+Logo',
}));
}
}, [initialData]);
// 필드 변경 핸들러
const handleChange = useCallback((field: string, value: string | number | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
@@ -438,11 +448,21 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<p className="text-sm text-gray-500">750 X 250px, 10MB PNG, JPEG, GIF</p>
{!isViewMode && (
<Button variant="outline" className="mt-2">
</Button>
{formData.logoUrl ? (
<img
src={formData.logoUrl}
alt="회사 로고"
className="max-h-[100px] max-w-[300px] object-contain mx-auto"
/>
) : (
<>
<p className="text-sm text-gray-500">750 X 250px, 10MB PNG, JPEG, GIF</p>
{!isViewMode && (
<Button variant="outline" className="mt-2">
</Button>
)}
</>
)}
</div>
</div>

View File

@@ -30,6 +30,7 @@ import {
import { ProposalDocument } from './ProposalDocument';
import { ExpenseReportDocument } from './ExpenseReportDocument';
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
import { printArea } from '@/lib/print-utils';
import type {
DocumentType,
DocumentDetailModalProps,
@@ -68,7 +69,7 @@ export function DocumentDetailModal({
};
const handlePrint = () => {
window.print();
printArea({ title: `${getDocumentTitle()} 인쇄` });
};
const handleSharePdf = () => {
@@ -107,8 +108,8 @@ export function DocumentDetailModal({
<DialogTitle>{getDocumentTitle()} </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 */}
<div className="flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold">{getDocumentTitle()} </h2>
<Button
variant="ghost"
@@ -120,8 +121,8 @@ export function DocumentDetailModal({
</Button>
</div>
{/* 버튼 영역 - 고정 */}
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
{/* 기안함 모드 + 임시저장 상태: 복제, 상신, 인쇄 */}
{mode === 'draft' && documentStatus === 'draft' && (
<>
@@ -191,8 +192,8 @@ export function DocumentDetailModal({
</DropdownMenu> */}
</div>
{/* 문서 영역 - 스크롤 */}
<div className="flex-1 overflow-y-auto bg-gray-100 p-6">
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg">
{renderDocument()}
</div>

View File

@@ -0,0 +1,605 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { BiddingDetail, BiddingDetailFormData } from './types';
import {
BIDDING_STATUS_OPTIONS,
BIDDING_STATUS_STYLES,
BIDDING_STATUS_LABELS,
VAT_TYPE_OPTIONS,
getEmptyBiddingDetailFormData,
biddingDetailToFormData,
} from './types';
import { updateBidding } from './actions';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
interface BiddingDetailFormProps {
mode: 'view' | 'edit';
biddingId: string;
initialData?: BiddingDetail;
}
export default function BiddingDetailForm({
mode,
biddingId,
initialData,
}: BiddingDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const _isEditMode = mode === 'edit';
// 폼 데이터
const [formData, setFormData] = useState<BiddingDetailFormData>(
initialData ? biddingDetailToFormData(initialData) : getEmptyBiddingDetailFormData()
);
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
// 다이얼로그 상태
const [showSaveDialog, setShowSaveDialog] = useState(false);
// 공과 합계 계산
const expenseTotal = useMemo(() => {
return formData.expenseItems.reduce((sum, item) => sum + item.amount, 0);
}, [formData.expenseItems]);
// 견적 상세 합계 계산 (견적 상세 페이지와 동일)
const estimateDetailTotals = useMemo(() => {
return formData.estimateDetailItems.reduce(
(acc, item) => ({
weight: acc.weight + (item.weight || 0),
area: acc.area + (item.area || 0),
steelScreen: acc.steelScreen + (item.steelScreen || 0),
caulking: acc.caulking + (item.caulking || 0),
rail: acc.rail + (item.rail || 0),
bottom: acc.bottom + (item.bottom || 0),
boxReinforce: acc.boxReinforce + (item.boxReinforce || 0),
shaft: acc.shaft + (item.shaft || 0),
painting: acc.painting + (item.painting || 0),
motor: acc.motor + (item.motor || 0),
controller: acc.controller + (item.controller || 0),
widthConstruction: acc.widthConstruction + (item.widthConstruction || 0),
heightConstruction: acc.heightConstruction + (item.heightConstruction || 0),
unitPrice: acc.unitPrice + (item.unitPrice || 0),
expense: acc.expense + (item.expense || 0),
quantity: acc.quantity + (item.quantity || 0),
cost: acc.cost + (item.cost || 0),
costExecution: acc.costExecution + (item.costExecution || 0),
marginCost: acc.marginCost + (item.marginCost || 0),
marginCostExecution: acc.marginCostExecution + (item.marginCostExecution || 0),
expenseExecution: acc.expenseExecution + (item.expenseExecution || 0),
}),
{
weight: 0,
area: 0,
steelScreen: 0,
caulking: 0,
rail: 0,
bottom: 0,
boxReinforce: 0,
shaft: 0,
painting: 0,
motor: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
unitPrice: 0,
expense: 0,
quantity: 0,
cost: 0,
costExecution: 0,
marginCost: 0,
marginCostExecution: 0,
expenseExecution: 0,
}
);
}, [formData.estimateDetailItems]);
// 네비게이션 핸들러
const handleBack = useCallback(() => {
router.push('/ko/juil/project/bidding');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/juil/project/bidding/${biddingId}/edit`);
}, [router, biddingId]);
const handleCancel = useCallback(() => {
router.push(`/ko/juil/project/bidding/${biddingId}`);
}, [router, biddingId]);
// 저장 핸들러
const handleSave = useCallback(() => {
setShowSaveDialog(true);
}, []);
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
const result = await updateBidding(biddingId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/juil/project/bidding/${biddingId}`);
router.refresh();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, biddingId, formData]);
// 필드 변경 핸들러
const handleFieldChange = useCallback(
(field: keyof BiddingDetailFormData, value: string | number) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
},
[]
);
// 헤더 액션 버튼
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBack}>
</Button>
<Button onClick={handleEdit}></Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</div>
);
return (
<PageLayout>
<PageHeader
title={isViewMode ? '입찰 상세' : '입찰 수정'}
actions={headerActions}
/>
<div className="space-y-6">
{/* 입찰 정보 섹션 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* 입찰번호 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.biddingCode} disabled className="bg-muted" />
</div>
{/* 입찰자 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.bidderName} disabled className="bg-muted" />
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.partnerName} disabled className="bg-muted" />
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.projectName} disabled className="bg-muted" />
</div>
{/* 입찰일자 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={formData.biddingDate} disabled className="bg-muted" />
) : (
<Input
type="date"
value={formData.biddingDate}
onChange={(e) => handleFieldChange('biddingDate', e.target.value)}
/>
)}
</div>
{/* 개소 */}
<div className="space-y-2">
<Label></Label>
<Input value={formData.totalCount} disabled className="bg-muted" />
</div>
{/* 공사기간 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
{isViewMode ? (
<Input
value={`${formData.constructionStartDate} ~ ${formData.constructionEndDate}`}
disabled
className="bg-muted"
/>
) : (
<>
<Input
type="date"
value={formData.constructionStartDate}
onChange={(e) =>
handleFieldChange('constructionStartDate', e.target.value)
}
className="flex-1"
/>
<span>~</span>
<Input
type="date"
value={formData.constructionEndDate}
onChange={(e) =>
handleFieldChange('constructionEndDate', e.target.value)
}
className="flex-1"
/>
</>
)}
</div>
</div>
{/* 부가세 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input
value={
VAT_TYPE_OPTIONS.find((opt) => opt.value === formData.vatType)?.label ||
formData.vatType
}
disabled
className="bg-muted"
/>
) : (
<Select
value={formData.vatType}
onValueChange={(value) => handleFieldChange('vatType', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* 입찰금액 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formatAmount(formData.biddingAmount)}
disabled
className="bg-muted text-right font-medium"
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<div
className={`flex h-10 items-center rounded-md border px-3 ${BIDDING_STATUS_STYLES[formData.status]}`}
>
{BIDDING_STATUS_LABELS[formData.status]}
</div>
) : (
<Select
value={formData.status}
onValueChange={(value) => handleFieldChange('status', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BIDDING_STATUS_OPTIONS.filter((opt) => opt.value !== 'all').map(
(option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
)}
</div>
{/* 투찰일 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input
value={formData.submissionDate || '-'}
disabled
className="bg-muted"
/>
) : (
<Input
type="date"
value={formData.submissionDate}
onChange={(e) => handleFieldChange('submissionDate', e.target.value)}
/>
)}
</div>
{/* 확정일 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input
value={formData.confirmDate || '-'}
disabled
className="bg-muted"
/>
) : (
<Input
type="date"
value={formData.confirmDate}
onChange={(e) => handleFieldChange('confirmDate', e.target.value)}
/>
)}
</div>
</div>
{/* 비고 */}
<div className="mt-4 space-y-2">
<Label></Label>
{isViewMode ? (
<Textarea
value={formData.remarks || '-'}
disabled
className="min-h-[80px] resize-none bg-muted"
/>
) : (
<Textarea
value={formData.remarks}
onChange={(e) => handleFieldChange('remarks', e.target.value)}
placeholder="비고를 입력하세요"
className="min-h-[80px] resize-none"
/>
)}
</div>
</CardContent>
</Card>
{/* 공과 상세 섹션 (읽기 전용) */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60%]"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{formData.expenseItems.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="h-24 text-center text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
<>
{formData.expenseItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell className="text-right">
{formatAmount(item.amount)}
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/50 font-medium">
<TableCell></TableCell>
<TableCell className="text-right">
{formatAmount(expenseTotal)}
</TableCell>
</TableRow>
</>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 견적 상세 섹션 (읽기 전용 - 견적 상세 페이지와 동일) */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto max-h-[600px] rounded-md border">
<Table>
<TableHeader className="sticky top-0 bg-white z-10">
<TableRow className="bg-gray-100">
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[90px] text-right">,</TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[90px] text-right">+</TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[50px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{formData.estimateDetailItems.length === 0 ? (
<TableRow>
<TableCell colSpan={26} className="h-24 text-center text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
<>
{formData.estimateDetailItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="bg-gray-50">{item.name}</TableCell>
<TableCell className="bg-gray-50">{item.material}</TableCell>
<TableCell className="text-right bg-gray-50">{item.width?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{item.height?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{item.weight?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{item.area?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.steelScreen || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.caulking || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.rail || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.bottom || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.boxReinforce || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.shaft || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.painting || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.motor || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.controller || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.widthConstruction || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.heightConstruction || 0)}</TableCell>
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(item.unitPrice || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{item.expenseRate || 0}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.expense || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{item.quantity || 0}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.cost || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.costExecution || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.marginCost || 0)}</TableCell>
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(item.marginCostExecution || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.expenseExecution || 0)}</TableCell>
</TableRow>
))}
{/* 합계 행 */}
<TableRow className="bg-orange-50 font-medium border-t-2 border-orange-300">
<TableCell colSpan={4} className="text-center font-bold"></TableCell>
<TableCell className="text-right">{estimateDetailTotals.weight.toFixed(2)}</TableCell>
<TableCell className="text-right">{estimateDetailTotals.area.toFixed(2)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.steelScreen)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.caulking)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.rail)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.bottom)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.boxReinforce)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.shaft)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.painting)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.motor)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.controller)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.widthConstruction)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.heightConstruction)}</TableCell>
<TableCell className="text-right font-bold">{formatAmount(estimateDetailTotals.unitPrice)}</TableCell>
<TableCell className="text-right">-</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.expense)}</TableCell>
<TableCell className="text-right">{estimateDetailTotals.quantity}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.cost)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.costExecution)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.marginCost)}</TableCell>
<TableCell className="text-right font-bold">{formatAmount(estimateDetailTotals.marginCostExecution)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.expenseExecution)}</TableCell>
</TableRow>
</>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -0,0 +1,574 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Clock, Trophy, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Bidding, BiddingStats } from './types';
import {
BIDDING_STATUS_OPTIONS,
BIDDING_SORT_OPTIONS,
BIDDING_STATUS_STYLES,
BIDDING_STATUS_LABELS,
} from './types';
import { getBiddingList, getBiddingStats, deleteBidding, deleteBiddings } from './actions';
// 테이블 컬럼 정의 (체크박스, 번호, 입찰번호, 거래처, 현장명, 입찰자, 총 개소, 입찰금액, 입찰일, 투찰일, 확정일, 상태, 비고, 작업)
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'biddingCode', label: '입찰번호', className: 'w-[120px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'bidderName', label: '입찰자', className: 'w-[80px]' },
{ key: 'totalCount', label: '총 개소', className: 'w-[80px] text-center' },
{ key: 'biddingAmount', label: '입찰금액', className: 'w-[120px] text-right' },
{ key: 'bidDate', label: '입찰일', className: 'w-[100px] text-center' },
{ key: 'submissionDate', label: '투찰일', className: 'w-[100px] text-center' },
{ key: 'confirmDate', label: '확정일', className: 'w-[100px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'remarks', label: '비고', className: 'w-[120px]' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: '1', label: '이사대표' },
{ value: '2', label: '야사건설' },
{ value: '3', label: '여의건설' },
];
// 목업 입찰자 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_BIDDERS: MultiSelectOption[] = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
}
interface BiddingListClientProps {
initialData?: Bidding[];
initialStats?: BiddingStats;
}
export default function BiddingListClient({ initialData = [], initialStats }: BiddingListClientProps) {
const router = useRouter();
// 상태
const [biddings, setBiddings] = useState<Bidding[]>(initialData);
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [bidderFilters, setBidderFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('biddingDateDesc');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'awarded'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getBiddingList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getBiddingStats(),
]);
if (listResult.success && listResult.data) {
setBiddings(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredBiddings = useMemo(() => {
return biddings.filter((bidding) => {
// 상태 탭 필터
if (activeStatTab === 'waiting' && bidding.status !== 'waiting') return false;
if (activeStatTab === 'awarded' && bidding.status !== 'awarded') return false;
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(bidding.partnerId)) return false;
}
// 입찰자 필터 (다중선택 - 빈 배열 = 전체)
if (bidderFilters.length > 0) {
if (!bidderFilters.includes(bidding.bidderId)) return false;
}
// 상태 필터
if (statusFilter !== 'all' && bidding.status !== statusFilter) return false;
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
bidding.projectName.toLowerCase().includes(search) ||
bidding.biddingCode.toLowerCase().includes(search) ||
bidding.partnerName.toLowerCase().includes(search)
);
}
return true;
});
}, [biddings, activeStatTab, partnerFilters, bidderFilters, statusFilter, searchValue]);
// 정렬
const sortedBiddings = useMemo(() => {
const sorted = [...filteredBiddings];
switch (sortBy) {
case 'biddingDateDesc':
sorted.sort((a, b) => {
if (!a.biddingDate) return 1;
if (!b.biddingDate) return -1;
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
});
break;
case 'biddingDateAsc':
sorted.sort((a, b) => {
if (!a.biddingDate) return 1;
if (!b.biddingDate) return -1;
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
});
break;
case 'submissionDateDesc':
sorted.sort((a, b) => {
if (!a.submissionDate) return 1;
if (!b.submissionDate) return -1;
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
});
break;
case 'confirmDateDesc':
sorted.sort((a, b) => {
if (!a.confirmDate) return 1;
if (!b.confirmDate) return -1;
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
});
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
}
return sorted;
}, [filteredBiddings, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedBiddings.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedBiddings.slice(start, start + itemsPerPage);
}, [sortedBiddings, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(bidding: Bidding) => {
router.push(`/ko/juil/project/bidding/${bidding.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, biddingId: string) => {
e.stopPropagation();
router.push(`/ko/juil/project/bidding/${biddingId}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, biddingId: string) => {
e.stopPropagation();
setDeleteTargetId(biddingId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteBidding(deleteTargetId);
if (result.success) {
toast.success('입찰이 삭제되었습니다.');
setBiddings((prev) => prev.filter((b) => b.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteBiddings(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
// 테이블 행 렌더링
const renderTableRow = useCallback(
(bidding: Bidding, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(bidding.id);
return (
<TableRow
key={bidding.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(bidding)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(bidding.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{bidding.biddingCode}</TableCell>
<TableCell>{bidding.partnerName}</TableCell>
<TableCell>{bidding.projectName}</TableCell>
<TableCell>{bidding.bidderName}</TableCell>
<TableCell className="text-center">{bidding.totalCount}</TableCell>
<TableCell className="text-right">{formatAmount(bidding.biddingAmount)}</TableCell>
<TableCell className="text-center">{formatDate(bidding.bidDate)}</TableCell>
<TableCell className="text-center">{formatDate(bidding.submissionDate)}</TableCell>
<TableCell className="text-center">{formatDate(bidding.confirmDate)}</TableCell>
<TableCell className="text-center">
<span className={BIDDING_STATUS_STYLES[bidding.status]}>
{BIDDING_STATUS_LABELS[bidding.status]}
</span>
</TableCell>
<TableCell className="truncate max-w-[120px]" title={bidding.remarks}>
{bidding.remarks || '-'}
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, bidding.id)}
>
<Pencil className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(bidding: Bidding, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={bidding.projectName}
subtitle={bidding.biddingCode}
badge={BIDDING_STATUS_LABELS[bidding.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(bidding)}
details={[
{ label: '거래처', value: bidding.partnerName },
{ label: '입찰금액', value: `${formatAmount(bidding.biddingAmount)}` },
{ label: '입찰일자', value: formatDate(bidding.biddingDate) },
{ label: '총 개소', value: `${bidding.totalCount}` },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜 필터) - 등록 버튼 없음 (견적완료 시 자동 등록)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// Stats 카드 데이터 (전체 입찰, 입찰대기, 낙찰)
const statsCardsData: StatCard[] = [
{
label: '전체 입찰',
value: stats?.total ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '입찰대기',
value: stats?.waiting ?? 0,
icon: Clock,
iconColor: 'text-orange-500',
onClick: () => setActiveStatTab('waiting'),
isActive: activeStatTab === 'waiting',
},
{
label: '낙찰',
value: stats?.awarded ?? 0,
icon: Trophy,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('awarded'),
isActive: activeStatTab === 'awarded',
},
];
// 테이블 헤더 액션 (총 건수 + 필터 4개: 거래처, 입찰자, 상태, 정렬)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedBiddings.length}
</span>
{/* 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 입찰자 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_BIDDERS}
value={bidderFilters}
onChange={setBidderFilters}
placeholder="입찰자"
searchPlaceholder="입찰자 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{BIDDING_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="최신순 (입찰일)" />
</SelectTrigger>
<SelectContent>
{BIDDING_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="입찰관리"
description="입찰을 관리합니다 (견적완료 시 자동 등록)"
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="입찰번호, 거래처, 현장명 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedBiddings}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedBiddings.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,574 @@
'use server';
import type {
Bidding,
BiddingStats,
BiddingListResponse,
BiddingFilter,
BiddingDetail,
BiddingDetailFormData,
ExpenseItem,
EstimateDetailItem,
} from './types';
// 목업 데이터
const MOCK_BIDDINGS: Bidding[] = [
{
id: '1',
biddingCode: 'BID-2025-001',
partnerId: '1',
partnerName: '이사대표',
projectName: '광장 아파트',
biddingDate: '2025-01-25',
totalCount: 15,
biddingAmount: 71000000,
bidDate: '2025-01-20',
submissionDate: '2025-01-22',
confirmDate: '2025-01-25',
status: 'awarded',
bidderId: 'hong',
bidderName: '홍길동',
remarks: '',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
createdBy: 'system',
estimateId: '1',
estimateCode: 'EST-2025-001',
},
{
id: '2',
biddingCode: 'BID-2025-002',
partnerId: '2',
partnerName: '야사건설',
projectName: '대림아파트',
biddingDate: '2025-01-20',
totalCount: 22,
biddingAmount: 100000000,
bidDate: '2025-01-18',
submissionDate: null,
confirmDate: null,
status: 'waiting',
bidderId: 'kim',
bidderName: '김철수',
remarks: '',
createdAt: '2025-01-02',
updatedAt: '2025-01-02',
createdBy: 'system',
estimateId: '2',
estimateCode: 'EST-2025-002',
},
{
id: '3',
biddingCode: 'BID-2025-003',
partnerId: '3',
partnerName: '여의건설',
projectName: '현장아파트',
biddingDate: '2025-01-18',
totalCount: 18,
biddingAmount: 85000000,
bidDate: '2025-01-15',
submissionDate: '2025-01-16',
confirmDate: '2025-01-18',
status: 'awarded',
bidderId: 'hong',
bidderName: '홍길동',
remarks: '',
createdAt: '2025-01-03',
updatedAt: '2025-01-03',
createdBy: 'system',
estimateId: '3',
estimateCode: 'EST-2025-003',
},
{
id: '4',
biddingCode: 'BID-2025-004',
partnerId: '1',
partnerName: '이사대표',
projectName: '송파타워',
biddingDate: '2025-01-15',
totalCount: 30,
biddingAmount: 120000000,
bidDate: '2025-01-12',
submissionDate: '2025-01-13',
confirmDate: '2025-01-15',
status: 'failed',
bidderId: 'lee',
bidderName: '이영희',
remarks: '가격 경쟁력 부족',
createdAt: '2025-01-04',
updatedAt: '2025-01-04',
createdBy: 'system',
estimateId: '4',
estimateCode: 'EST-2025-004',
},
{
id: '5',
biddingCode: 'BID-2025-005',
partnerId: '2',
partnerName: '야사건설',
projectName: '강남센터',
biddingDate: '2025-01-12',
totalCount: 25,
biddingAmount: 95000000,
bidDate: '2025-01-10',
submissionDate: '2025-01-11',
confirmDate: null,
status: 'submitted',
bidderId: 'hong',
bidderName: '홍길동',
remarks: '',
createdAt: '2025-01-05',
updatedAt: '2025-01-05',
createdBy: 'system',
estimateId: '5',
estimateCode: 'EST-2025-005',
},
{
id: '6',
biddingCode: 'BID-2025-006',
partnerId: '3',
partnerName: '여의건설',
projectName: '목동센터',
biddingDate: '2025-01-10',
totalCount: 12,
biddingAmount: 78000000,
bidDate: '2025-01-08',
submissionDate: '2025-01-09',
confirmDate: '2025-01-10',
status: 'invalid',
bidderId: 'kim',
bidderName: '김철수',
remarks: '입찰 조건 미충족',
createdAt: '2025-01-06',
updatedAt: '2025-01-06',
createdBy: 'system',
estimateId: '6',
estimateCode: 'EST-2025-006',
},
{
id: '7',
biddingCode: 'BID-2025-007',
partnerId: '1',
partnerName: '이사대표',
projectName: '서초타워',
biddingDate: '2025-01-08',
totalCount: 35,
biddingAmount: 150000000,
bidDate: '2025-01-05',
submissionDate: null,
confirmDate: null,
status: 'waiting',
bidderId: 'lee',
bidderName: '이영희',
remarks: '',
createdAt: '2025-01-07',
updatedAt: '2025-01-07',
createdBy: 'system',
estimateId: '7',
estimateCode: 'EST-2025-007',
},
{
id: '8',
biddingCode: 'BID-2025-008',
partnerId: '2',
partnerName: '야사건설',
projectName: '청담프로젝트',
biddingDate: '2025-01-05',
totalCount: 40,
biddingAmount: 200000000,
bidDate: '2025-01-03',
submissionDate: '2025-01-04',
confirmDate: '2025-01-05',
status: 'awarded',
bidderId: 'hong',
bidderName: '홍길동',
remarks: '',
createdAt: '2025-01-08',
updatedAt: '2025-01-08',
createdBy: 'system',
estimateId: '8',
estimateCode: 'EST-2025-008',
},
{
id: '9',
biddingCode: 'BID-2025-009',
partnerId: '3',
partnerName: '여의건설',
projectName: '잠실센터',
biddingDate: '2025-01-03',
totalCount: 20,
biddingAmount: 88000000,
bidDate: '2025-01-01',
submissionDate: null,
confirmDate: null,
status: 'hold',
bidderId: 'kim',
bidderName: '김철수',
remarks: '검토 대기 중',
createdAt: '2025-01-09',
updatedAt: '2025-01-09',
createdBy: 'system',
estimateId: '9',
estimateCode: 'EST-2025-009',
},
{
id: '10',
biddingCode: 'BID-2025-010',
partnerId: '1',
partnerName: '이사대표',
projectName: '역삼빌딩',
biddingDate: '2025-01-01',
totalCount: 10,
biddingAmount: 65000000,
bidDate: '2024-12-28',
submissionDate: null,
confirmDate: null,
status: 'waiting',
bidderId: 'lee',
bidderName: '이영희',
remarks: '',
createdAt: '2025-01-10',
updatedAt: '2025-01-10',
createdBy: 'system',
estimateId: '10',
estimateCode: 'EST-2025-010',
},
];
// 입찰 목록 조회
export async function getBiddingList(filter?: BiddingFilter): Promise<{
success: boolean;
data?: BiddingListResponse;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
let filteredData = [...MOCK_BIDDINGS];
// 검색 필터
if (filter?.search) {
const search = filter.search.toLowerCase();
filteredData = filteredData.filter(
(item) =>
item.biddingCode.toLowerCase().includes(search) ||
item.partnerName.toLowerCase().includes(search) ||
item.projectName.toLowerCase().includes(search)
);
}
// 상태 필터
if (filter?.status && filter.status !== 'all') {
filteredData = filteredData.filter((item) => item.status === filter.status);
}
// 거래처 필터
if (filter?.partnerId && filter.partnerId !== 'all') {
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
}
// 입찰자 필터
if (filter?.bidderId && filter.bidderId !== 'all') {
filteredData = filteredData.filter((item) => item.bidderId === filter.bidderId);
}
// 날짜 필터
if (filter?.startDate) {
filteredData = filteredData.filter(
(item) => item.biddingDate && item.biddingDate >= filter.startDate!
);
}
if (filter?.endDate) {
filteredData = filteredData.filter(
(item) => item.biddingDate && item.biddingDate <= filter.endDate!
);
}
// 정렬
const sortBy = filter?.sortBy || 'biddingDateDesc';
switch (sortBy) {
case 'biddingDateDesc':
filteredData.sort((a, b) => {
if (!a.biddingDate) return 1;
if (!b.biddingDate) return -1;
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
});
break;
case 'biddingDateAsc':
filteredData.sort((a, b) => {
if (!a.biddingDate) return 1;
if (!b.biddingDate) return -1;
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
});
break;
case 'submissionDateDesc':
filteredData.sort((a, b) => {
if (!a.submissionDate) return 1;
if (!b.submissionDate) return -1;
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
});
break;
case 'confirmDateDesc':
filteredData.sort((a, b) => {
if (!a.confirmDate) return 1;
if (!b.confirmDate) return -1;
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
});
break;
case 'partnerNameAsc':
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
}
// 페이지네이션
const page = filter?.page || 1;
const size = filter?.size || 20;
const startIndex = (page - 1) * size;
const paginatedData = filteredData.slice(startIndex, startIndex + size);
return {
success: true,
data: {
items: paginatedData,
total: filteredData.length,
page,
size,
totalPages: Math.ceil(filteredData.length / size),
},
};
} catch (error) {
console.error('getBiddingList error:', error);
return { success: false, error: '입찰 목록을 불러오는데 실패했습니다.' };
}
}
// 입찰 통계 조회
export async function getBiddingStats(): Promise<{
success: boolean;
data?: BiddingStats;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 100));
const stats: BiddingStats = {
total: MOCK_BIDDINGS.length,
waiting: MOCK_BIDDINGS.filter((b) => b.status === 'waiting').length,
awarded: MOCK_BIDDINGS.filter((b) => b.status === 'awarded').length,
};
return { success: true, data: stats };
} catch (error) {
console.error('getBiddingStats error:', error);
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
}
}
// 입찰 단건 조회
export async function getBidding(id: string): Promise<{
success: boolean;
data?: Bidding;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 200));
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
if (!bidding) {
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
}
return { success: true, data: bidding };
} catch (error) {
console.error('getBidding error:', error);
return { success: false, error: '입찰 정보를 불러오는데 실패했습니다.' };
}
}
// 입찰 삭제
export async function deleteBidding(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
if (index === -1) {
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
}
return { success: true };
} catch (error) {
console.error('deleteBidding error:', error);
return { success: false, error: '입찰 삭제에 실패했습니다.' };
}
}
// 입찰 일괄 삭제
export async function deleteBiddings(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
return { success: true, deletedCount: ids.length };
} catch (error) {
console.error('deleteBiddings error:', error);
return { success: false, error: '일괄 삭제에 실패했습니다.' };
}
}
// 공과 상세 목업 데이터
const MOCK_EXPENSE_ITEMS: ExpenseItem[] = [
{ id: '1', name: '설계비', amount: 5000000 },
{ id: '2', name: '운반비', amount: 3000000 },
{ id: '3', name: '기타경비', amount: 2000000 },
];
// 견적 상세 목업 데이터
const MOCK_ESTIMATE_DETAIL_ITEMS: EstimateDetailItem[] = [
{
id: '1',
no: 1,
name: '방화문',
material: 'SUS304',
width: 1000,
height: 2100,
quantity: 10,
box: 2,
coating: 1,
batting: 0,
mounting: 1,
shift: 0,
painting: 1,
motor: 0,
controller: 0,
unitPrice: 1500000,
expense: 100000,
expenseQuantity: 10,
totalPrice: 16000000,
marginRate: 15,
marginCost: 2400000,
progressPayment: 8000000,
execution: 13600000,
},
{
id: '2',
no: 2,
name: '자동문',
material: 'AL',
width: 1800,
height: 2400,
quantity: 5,
box: 1,
coating: 1,
batting: 1,
mounting: 1,
shift: 1,
painting: 0,
motor: 1,
controller: 1,
unitPrice: 3500000,
expense: 200000,
expenseQuantity: 5,
totalPrice: 18500000,
marginRate: 18,
marginCost: 3330000,
progressPayment: 9250000,
execution: 15170000,
},
{
id: '3',
no: 3,
name: '셔터',
material: 'STEEL',
width: 3000,
height: 3500,
quantity: 3,
box: 1,
coating: 1,
batting: 0,
mounting: 1,
shift: 0,
painting: 1,
motor: 1,
controller: 1,
unitPrice: 8000000,
expense: 500000,
expenseQuantity: 3,
totalPrice: 25500000,
marginRate: 20,
marginCost: 5100000,
progressPayment: 12750000,
execution: 20400000,
},
];
// 입찰 상세 조회
export async function getBiddingDetail(id: string): Promise<{
success: boolean;
data?: BiddingDetail;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
if (!bidding) {
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
}
// 상세 데이터 생성
const biddingDetail: BiddingDetail = {
...bidding,
constructionStartDate: '2025-02-01',
constructionEndDate: '2025-04-30',
vatType: 'excluded',
expenseItems: MOCK_EXPENSE_ITEMS,
estimateDetailItems: MOCK_ESTIMATE_DETAIL_ITEMS,
};
return { success: true, data: biddingDetail };
} catch (error) {
console.error('getBiddingDetail error:', error);
return { success: false, error: '입찰 상세를 불러오는데 실패했습니다.' };
}
}
// 입찰 수정
export async function updateBidding(
id: string,
data: Partial<BiddingDetailFormData>
): Promise<{
success: boolean;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
if (index === -1) {
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
}
// 목업에서는 실제 업데이트하지 않음
console.log('Updating bidding:', id, data);
return { success: true };
} catch (error) {
console.error('updateBidding error:', error);
return { success: false, error: '입찰 수정에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,4 @@
export { default as BiddingListClient } from './BiddingListClient';
export { default as BiddingDetailForm } from './BiddingDetailForm';
export * from './types';
export * from './actions';

View File

@@ -0,0 +1,263 @@
/**
* 주일 기업 - 입찰관리 타입 정의
*
* 입찰 데이터는 견적 상세에서 견적완료 시 자동 등록됨
* (별도 등록 기능 없음, 상세/수정만 가능)
*/
// 입찰 상태
export type BiddingStatus =
| 'waiting' // 입찰대기
| 'submitted' // 투찰
| 'failed' // 탈락
| 'invalid' // 유찰
| 'awarded' // 낙찰
| 'hold'; // 보류
// 입찰 타입
export interface Bidding {
id: string;
biddingCode: string; // 입찰번호
// 기본 정보
partnerId: string; // 거래처 ID
partnerName: string; // 거래처명
projectName: string; // 현장명
// 입찰 정보
biddingDate: string | null; // 입찰일자
totalCount: number; // 총 개소
biddingAmount: number; // 입찰금액
bidDate: string | null; // 입찰일
submissionDate: string | null; // 투찰일
confirmDate: string | null; // 확정일
// 상태 정보
status: BiddingStatus;
// 입찰자
bidderId: string;
bidderName: string;
// 비고
remarks: string;
// 메타 정보
createdAt: string;
updatedAt: string;
createdBy: string;
// 연결된 견적 정보 (견적완료 시 자동 연결)
estimateId: string;
estimateCode: string;
}
// 입찰 통계
export interface BiddingStats {
total: number; // 전체 입찰
waiting: number; // 입찰대기
awarded: number; // 낙찰
}
// 입찰 필터
export interface BiddingFilter {
search?: string;
status?: BiddingStatus | 'all';
partnerId?: string;
bidderId?: string;
startDate?: string;
endDate?: string;
sortBy?: string;
page?: number;
size?: number;
}
// API 응답 타입
export interface BiddingListResponse {
items: Bidding[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 상태 옵션
export const BIDDING_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'waiting', label: '입찰대기' },
{ value: 'submitted', label: '투찰' },
{ value: 'failed', label: '탈락' },
{ value: 'invalid', label: '유찰' },
{ value: 'awarded', label: '낙찰' },
{ value: 'hold', label: '보류' },
];
// 정렬 옵션
export const BIDDING_SORT_OPTIONS = [
{ value: 'biddingDateDesc', label: '최신순 (입찰일)' },
{ value: 'biddingDateAsc', label: '등록순 (입찰일)' },
{ value: 'submissionDateDesc', label: '투찰일 최신순' },
{ value: 'confirmDateDesc', label: '확정일 최신순' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'projectNameAsc', label: '현장명 오름차순' },
{ value: 'projectNameDesc', label: '현장명 내림차순' },
];
// 상태별 스타일
export const BIDDING_STATUS_STYLES: Record<BiddingStatus, string> = {
waiting: 'text-orange-500 font-medium',
submitted: 'text-blue-500 font-medium',
failed: 'text-red-500 font-medium',
invalid: 'text-gray-500 font-medium',
awarded: 'text-green-600 font-medium',
hold: 'text-gray-400 font-medium',
};
export const BIDDING_STATUS_LABELS: Record<BiddingStatus, string> = {
waiting: '입찰대기',
submitted: '투찰',
failed: '탈락',
invalid: '유찰',
awarded: '낙찰',
hold: '보류',
};
// =====================================================
// 입찰 상세 관련 타입
// =====================================================
// 공과 항목 (견적에서 가져옴)
export interface ExpenseItem {
id: string;
name: string; // 공과명
amount: number; // 금액
}
// 견적 상세 항목 (견적에서 가져옴 - estimates와 동일한 구조)
export interface EstimateDetailItem {
id: string;
no: number; // 번호
name: string; // 명칭
material: string; // 제품
width: number; // 가로 (M)
height: number; // 세로 (M)
quantity: number; // 수량
// 계산값들
weight: number; // 무게 = 면적*25
area: number; // 면적 = (가로×0.16)*(세로×0.5)
steelScreen: number; // 철제,스크린 = 면적*47500
caulking: number; // 코킹 = (세로*4)*단가
rail: number; // 레일 = (세로×0.2)*단가
bottom: number; // 하장 = 가로*단가
boxReinforce: number; // 박스+보강 = 가로*단가
shaft: number; // 샤프트 = 가로*단가
painting: number; // 도장 (셀렉트)
motor: number; // 모터 (셀렉트)
controller: number; // 제어기 (셀렉트)
widthConstruction: number; // 가로시공비
heightConstruction: number; // 세로시공비
unitPrice: number; // 단가
expenseRate: number; // 공과율
expense: number; // 공과
cost: number; // 원가
costExecution: number; // 원가실행
marginCost: number; // 마진원가
marginCostExecution: number; // 마진원가실행
expenseExecution: number; // 공과실행
}
// 입찰 상세 전체 데이터
export interface BiddingDetail extends Bidding {
// 공사기간
constructionStartDate: string;
constructionEndDate: string;
// 부가세
vatType: string;
// 공과 상세 (견적에서 가져옴 - 읽기 전용)
expenseItems: ExpenseItem[];
// 견적 상세 (견적에서 가져옴 - 읽기 전용)
estimateDetailItems: EstimateDetailItem[];
}
// 입찰 상세 폼 데이터 (수정용)
export interface BiddingDetailFormData {
// 입찰 정보
biddingCode: string;
bidderId: string;
bidderName: string;
partnerName: string;
projectName: string;
biddingDate: string;
totalCount: number;
constructionStartDate: string;
constructionEndDate: string;
vatType: string;
biddingAmount: number;
status: BiddingStatus;
submissionDate: string;
confirmDate: string;
remarks: string;
// 공과 상세 (읽기 전용)
expenseItems: ExpenseItem[];
// 견적 상세 (읽기 전용)
estimateDetailItems: EstimateDetailItem[];
}
// 부가세 옵션
export const VAT_TYPE_OPTIONS = [
{ value: 'included', label: '부가세 포함' },
{ value: 'excluded', label: '부가세 별도' },
];
// 빈 폼 데이터 생성
export function getEmptyBiddingDetailFormData(): BiddingDetailFormData {
return {
biddingCode: '',
bidderId: '',
bidderName: '',
partnerName: '',
projectName: '',
biddingDate: '',
totalCount: 0,
constructionStartDate: '',
constructionEndDate: '',
vatType: 'excluded',
biddingAmount: 0,
status: 'waiting',
submissionDate: '',
confirmDate: '',
remarks: '',
expenseItems: [],
estimateDetailItems: [],
};
}
// BiddingDetail을 FormData로 변환
export function biddingDetailToFormData(detail: BiddingDetail): BiddingDetailFormData {
return {
biddingCode: detail.biddingCode,
bidderId: detail.bidderId,
bidderName: detail.bidderName,
partnerName: detail.partnerName,
projectName: detail.projectName,
biddingDate: detail.biddingDate || '',
totalCount: detail.totalCount,
constructionStartDate: detail.constructionStartDate,
constructionEndDate: detail.constructionEndDate,
vatType: detail.vatType,
biddingAmount: detail.biddingAmount,
status: detail.status,
submissionDate: detail.submissionDate || '',
confirmDate: detail.confirmDate || '',
remarks: detail.remarks,
expenseItems: detail.expenseItems,
estimateDetailItems: detail.estimateDetailItems,
};
}

View File

@@ -0,0 +1,89 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Loader2 } from 'lucide-react';
import type { CategoryDialogProps } from './types';
/**
* 카테고리 추가/수정 다이얼로그
*/
export function CategoryDialog({
isOpen,
onOpenChange,
mode,
category,
onSubmit,
isLoading = false,
}: CategoryDialogProps) {
const [name, setName] = useState('');
// 다이얼로그 열릴 때 초기값 설정
useEffect(() => {
if (isOpen) {
if (mode === 'edit' && category) {
setName(category.name);
} else {
setName('');
}
}
}, [isOpen, mode, category]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onSubmit(name.trim());
setName('');
}
};
const title = mode === 'add' ? '카테고리 추가' : '카테고리 수정';
const submitText = mode === 'add' ? '등록' : '수정';
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
{/* 카테고리명 입력 */}
<div className="space-y-2">
<Label htmlFor="category-name"></Label>
<Input
id="category-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="카테고리를 입력해주세요"
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
</Button>
<Button type="submit" disabled={!name.trim() || isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
{submitText}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,145 @@
'use server';
import type { Category } from './types';
// ===== 목데이터 (추후 API 연동 시 교체) =====
let mockCategories: Category[] = [
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
{ id: '2', name: '모터', order: 2, isDefault: true },
{ id: '3', name: '공정자재', order: 3, isDefault: true },
{ id: '4', name: '철물', order: 4, isDefault: true },
];
// 다음 ID 생성
let nextId = 5;
// ===== 카테고리 목록 조회 =====
export async function getCategories(): Promise<{
success: boolean;
data?: Category[];
error?: string;
}> {
try {
// 목데이터 반환 (순서대로 정렬)
const sortedCategories = [...mockCategories].sort((a, b) => a.order - b.order);
return { success: true, data: sortedCategories };
} catch (error) {
console.error('[getCategories] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 카테고리 생성 =====
export async function createCategory(data: {
name: string;
}): Promise<{
success: boolean;
data?: Category;
error?: string;
}> {
try {
const newCategory: Category = {
id: String(nextId++),
name: data.name,
order: mockCategories.length + 1,
isDefault: false,
};
mockCategories.push(newCategory);
return { success: true, data: newCategory };
} catch (error) {
console.error('[createCategory] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 카테고리 수정 =====
export async function updateCategory(
id: string,
data: { name?: string }
): Promise<{
success: boolean;
data?: Category;
error?: string;
}> {
try {
const index = mockCategories.findIndex(c => c.id === id);
if (index === -1) {
return { success: false, error: '카테고리를 찾을 수 없습니다.' };
}
mockCategories[index] = {
...mockCategories[index],
...data,
};
return { success: true, data: mockCategories[index] };
} catch (error) {
console.error('[updateCategory] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 카테고리 삭제 =====
export async function deleteCategory(id: string): Promise<{
success: boolean;
error?: string;
errorType?: 'IN_USE' | 'DEFAULT' | 'GENERAL';
}> {
try {
const category = mockCategories.find(c => c.id === id);
if (!category) {
return { success: false, error: '카테고리를 찾을 수 없습니다.', errorType: 'GENERAL' };
}
// 기본 카테고리는 삭제 불가
if (category.isDefault) {
return {
success: false,
error: '기본 카테고리는 삭제가 불가합니다.',
errorType: 'DEFAULT'
};
}
// TODO: 품목 사용 여부 체크 로직 (추후 API 연동 시)
// 현재는 목데이터이므로 사용 중인 품목이 없다고 가정
// const itemsUsingCategory = await checkItemsUsingCategory(id);
// if (itemsUsingCategory.length > 0) {
// return {
// success: false,
// error: `"${category.name}"을(를) 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다.`,
// errorType: 'IN_USE'
// };
// }
mockCategories = mockCategories.filter(c => c.id !== id);
return { success: true };
} catch (error) {
console.error('[deleteCategory] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.', errorType: 'GENERAL' };
}
}
// ===== 카테고리 순서 변경 =====
export async function reorderCategories(
items: { id: string; sort_order: number }[]
): Promise<{
success: boolean;
error?: string;
}> {
try {
// 순서 업데이트
items.forEach(item => {
const category = mockCategories.find(c => c.id === item.id);
if (category) {
category.order = item.sort_order;
}
});
return { success: true };
} catch (error) {
console.error('[reorderCategories] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -0,0 +1,372 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { FolderTree, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { CategoryDialog } from './CategoryDialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import type { Category } from './types';
import {
getCategories,
createCategory,
updateCategory,
deleteCategory,
reorderCategories,
} from './actions';
export function CategoryManagement() {
// 카테고리 데이터
const [categories, setCategories] = useState<Category[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
// 입력 필드
const [newCategoryName, setNewCategoryName] = useState('');
// 다이얼로그 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add');
const [selectedCategory, setSelectedCategory] = useState<Category | undefined>();
// 삭제 확인 다이얼로그
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
// 드래그 상태
const [draggedItem, setDraggedItem] = useState<number | null>(null);
// 데이터 로드
const loadCategories = useCallback(async () => {
try {
setIsLoading(true);
const result = await getCategories();
if (result.success && result.data) {
setCategories(result.data);
} else {
toast.error(result.error || '카테고리 목록을 불러오는데 실패했습니다.');
}
} catch (error) {
console.error('카테고리 목록 조회 실패:', error);
toast.error('카테고리 목록을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
}, []);
// 초기 데이터 로드
useEffect(() => {
loadCategories();
}, [loadCategories]);
// 카테고리 추가 (입력 필드에서 직접)
const handleQuickAdd = async () => {
if (!newCategoryName.trim() || isSubmitting) return;
try {
setIsSubmitting(true);
const result = await createCategory({ name: newCategoryName.trim() });
if (result.success && result.data) {
setCategories(prev => [...prev, result.data!]);
setNewCategoryName('');
toast.success('카테고리가 추가되었습니다.');
} else {
toast.error(result.error || '카테고리 추가에 실패했습니다.');
}
} catch (error) {
console.error('카테고리 추가 실패:', error);
toast.error('카테고리 추가에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
// 카테고리 수정 다이얼로그 열기
const handleEdit = (category: Category) => {
setSelectedCategory(category);
setDialogMode('edit');
setDialogOpen(true);
};
// 카테고리 삭제 확인
const handleDelete = (category: Category) => {
setCategoryToDelete(category);
setDeleteDialogOpen(true);
};
// 삭제 실행
const confirmDelete = async () => {
if (!categoryToDelete || isSubmitting) return;
try {
setIsSubmitting(true);
const result = await deleteCategory(categoryToDelete.id);
if (result.success) {
setCategories(prev => prev.filter(c => c.id !== categoryToDelete.id));
toast.success('카테고리가 삭제되었습니다.');
} else {
// 삭제 실패 유형에 따른 메시지
toast.error(result.error || '카테고리 삭제에 실패했습니다.');
}
} catch (error) {
console.error('카테고리 삭제 실패:', error);
toast.error('카테고리 삭제에 실패했습니다.');
} finally {
setIsSubmitting(false);
setDeleteDialogOpen(false);
setCategoryToDelete(null);
}
};
// 다이얼로그 제출
const handleDialogSubmit = async (name: string) => {
if (dialogMode === 'edit' && selectedCategory) {
try {
setIsSubmitting(true);
const result = await updateCategory(selectedCategory.id, { name });
if (result.success) {
setCategories(prev => prev.map(c =>
c.id === selectedCategory.id ? { ...c, name } : c
));
toast.success('카테고리가 수정되었습니다.');
} else {
toast.error(result.error || '카테고리 수정에 실패했습니다.');
}
} catch (error) {
console.error('카테고리 수정 실패:', error);
toast.error('카테고리 수정에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
}
setDialogOpen(false);
};
// 드래그 시작
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedItem(index);
e.dataTransfer.effectAllowed = 'move';
};
// 드래그 종료 - 서버에 순서 저장
const handleDragEnd = async () => {
if (draggedItem === null) return;
setDraggedItem(null);
// 순서 변경 API 호출
try {
const items = categories.map((category, idx) => ({
id: category.id,
sort_order: idx + 1,
}));
const result = await reorderCategories(items);
if (result.success) {
toast.success('순서가 변경되었습니다.');
} else {
toast.error(result.error || '순서 변경에 실패했습니다.');
loadCategories();
}
} catch (error) {
console.error('순서 변경 실패:', error);
toast.error('순서 변경에 실패했습니다.');
// 실패시 원래 순서로 복구
loadCategories();
}
};
// 드래그 오버
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedItem === null || draggedItem === index) return;
const newCategories = [...categories];
const draggedCategory = newCategories[draggedItem];
newCategories.splice(draggedItem, 1);
newCategories.splice(index, 0, draggedCategory);
// 순서 업데이트 (로컬)
const reorderedCategories = newCategories.map((category, idx) => ({
...category,
order: idx + 1
}));
setCategories(reorderedCategories);
setDraggedItem(index);
};
// 키보드로 추가 (한글 IME 조합 중에는 무시)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
handleQuickAdd();
}
};
return (
<PageLayout>
<PageHeader
title="카테고리관리"
description="카테고리를 등록하고 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
icon={FolderTree}
/>
<div className="space-y-4">
{/* 카테고리 추가 입력 영역 */}
<Card>
<CardContent className="p-4">
<div className="flex gap-2">
<Input
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="카테고리를 입력해주세요"
className="flex-1"
disabled={isSubmitting}
/>
<Button
onClick={handleQuickAdd}
disabled={!newCategoryName.trim() || isSubmitting}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
</Button>
</div>
</CardContent>
</Card>
{/* 카테고리 목록 */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
) : (
<div className="divide-y">
{categories.map((category, index) => (
<div
key={category.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, index)}
className={`flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors cursor-move ${
draggedItem === index ? 'opacity-50 bg-muted' : ''
}`}
>
{/* 드래그 핸들 */}
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0" />
{/* 순서 번호 */}
<span className="text-sm text-muted-foreground w-8">
{index + 1}
</span>
{/* 카테고리명 */}
<span className="flex-1 font-medium">{category.name}</span>
{/* 액션 버튼 */}
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(category)}
className="h-8 w-8 p-0"
disabled={isSubmitting}
>
<Pencil className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(category)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
disabled={isSubmitting}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</div>
</div>
))}
{categories.length === 0 && (
<div className="px-4 py-8 text-center text-muted-foreground">
.
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* 안내 문구 */}
<p className="text-sm text-muted-foreground">
.
</p>
</div>
{/* 수정 다이얼로그 */}
<CategoryDialog
isOpen={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
category={selectedCategory}
onSubmit={handleDialogSubmit}
isLoading={isSubmitting}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{categoryToDelete?.name}&quot; ?
{categoryToDelete?.isDefault && (
<>
<br />
<span className="text-destructive font-medium">
.
</span>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isSubmitting || categoryToDelete?.isDefault}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -0,0 +1,21 @@
/**
* 카테고리 타입 정의
*/
export interface Category {
id: string;
name: string;
order: number;
isDefault?: boolean;
isActive?: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface CategoryDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
mode: 'add' | 'edit';
category?: Category;
onSubmit: (name: string) => void;
isLoading?: boolean;
}

View File

@@ -0,0 +1,6 @@
// Types
export type { ApprovalPerson, ElectronicApproval } from './types';
export { getEmptyElectronicApproval } from './types';
// Modals
export { ElectronicApprovalModal } from './modals';

View File

@@ -0,0 +1,298 @@
'use client';
import { useState, useCallback } from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ElectronicApproval, ApprovalPerson } from '../types';
// 목업 부서 목록
const MOCK_DEPARTMENTS = [
{ value: 'sales', label: '영업부' },
{ value: 'production', label: '생산부' },
{ value: 'quality', label: '품질부' },
{ value: 'management', label: '경영지원부' },
];
// 목업 직책 목록
const MOCK_POSITIONS = [
{ value: 'staff', label: '사원' },
{ value: 'senior', label: '주임' },
{ value: 'assistant_manager', label: '대리' },
{ value: 'manager', label: '과장' },
{ value: 'deputy_manager', label: '차장' },
{ value: 'general_manager', label: '부장' },
{ value: 'director', label: '이사' },
{ value: 'ceo', label: '대표' },
];
// 목업 사원 목록
const MOCK_EMPLOYEES = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
{ value: 'park', label: '박지영' },
{ value: 'choi', label: '최민수' },
];
interface ElectronicApprovalModalProps {
isOpen: boolean;
onClose: () => void;
approval: ElectronicApproval;
onSave: (approval: ElectronicApproval) => void;
}
export function ElectronicApprovalModal({
isOpen,
onClose,
approval,
onSave,
}: ElectronicApprovalModalProps) {
const [localApproval, setLocalApproval] = useState<ElectronicApproval>(approval);
// 결재자 추가
const handleAddApprover = useCallback(() => {
const newPerson: ApprovalPerson = {
id: String(Date.now()),
department: '',
position: '',
name: '',
};
setLocalApproval((prev) => ({
...prev,
approvers: [...prev.approvers, newPerson],
}));
}, []);
// 결재자 삭제
const handleRemoveApprover = useCallback((personId: string) => {
setLocalApproval((prev) => ({
...prev,
approvers: prev.approvers.filter((p) => p.id !== personId),
}));
}, []);
// 결재자 변경
const handleApproverChange = useCallback(
(personId: string, field: keyof ApprovalPerson, value: string) => {
setLocalApproval((prev) => ({
...prev,
approvers: prev.approvers.map((p) =>
p.id === personId ? { ...p, [field]: value } : p
),
}));
},
[]
);
// 참조자 추가
const handleAddReference = useCallback(() => {
const newPerson: ApprovalPerson = {
id: String(Date.now()),
department: '',
position: '',
name: '',
};
setLocalApproval((prev) => ({
...prev,
references: [...prev.references, newPerson],
}));
}, []);
// 참조자 삭제
const handleRemoveReference = useCallback((personId: string) => {
setLocalApproval((prev) => ({
...prev,
references: prev.references.filter((p) => p.id !== personId),
}));
}, []);
// 참조자 변경
const handleReferenceChange = useCallback(
(personId: string, field: keyof ApprovalPerson, value: string) => {
setLocalApproval((prev) => ({
...prev,
references: prev.references.map((p) =>
p.id === personId ? { ...p, [field]: value } : p
),
}));
},
[]
);
// 저장
const handleSave = useCallback(() => {
onSave(localApproval);
}, [localApproval, onSave]);
// 취소
const handleCancel = useCallback(() => {
setLocalApproval(approval);
onClose();
}, [approval, onClose]);
// 사람 선택 행 렌더링
const renderPersonRow = (
person: ApprovalPerson,
index: number,
type: 'approver' | 'reference'
) => {
const onChange =
type === 'approver' ? handleApproverChange : handleReferenceChange;
const onRemove =
type === 'approver' ? handleRemoveApprover : handleRemoveReference;
return (
<div key={person.id} className="flex items-center gap-2">
<Select
value={person.department || undefined}
onValueChange={(val) => onChange(person.id, 'department', val)}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="부서명" />
</SelectTrigger>
<SelectContent>
{MOCK_DEPARTMENTS.map((dept) => (
<SelectItem key={dept.value} value={dept.value}>
{dept.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-gray-400">/</span>
<Select
value={person.position || undefined}
onValueChange={(val) => onChange(person.id, 'position', val)}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="직책명" />
</SelectTrigger>
<SelectContent>
{MOCK_POSITIONS.map((pos) => (
<SelectItem key={pos.value} value={pos.value}>
{pos.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-gray-400">/</span>
<Select
value={person.name || undefined}
onValueChange={(val) => onChange(person.id, 'name', val)}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="이름" />
</SelectTrigger>
<SelectContent>
{MOCK_EMPLOYEES.map((emp) => (
<SelectItem key={emp.value} value={emp.value}>
{emp.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
onClick={() => onRemove(person.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="w-[95vw] max-w-[500px] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-lg font-bold"></DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 결재선 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium"></h3>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500"> / / </span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddApprover}
>
</Button>
</div>
</div>
<div className="space-y-2">
{localApproval.approvers.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4 border rounded-lg">
.
</p>
) : (
localApproval.approvers.map((person, index) =>
renderPersonRow(person, index, 'approver')
)
)}
</div>
</div>
{/* 참조 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium"></h3>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500"> / / </span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddReference}
>
</Button>
</div>
</div>
<div className="space-y-2">
{localApproval.references.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4 border rounded-lg">
.
</p>
) : (
localApproval.references.map((person, index) =>
renderPersonRow(person, index, 'reference')
)
)}
</div>
</div>
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-gray-800 hover:bg-gray-900">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { ElectronicApprovalModal } from './ElectronicApprovalModal';

View File

@@ -0,0 +1,21 @@
// 결재자/참조자 정보
export interface ApprovalPerson {
id: string;
department: string; // 부서
position: string; // 직책
name: string; // 이름
}
// 전자결재 정보
export interface ElectronicApproval {
approvers: ApprovalPerson[]; // 결재선
references: ApprovalPerson[]; // 참조
}
// 빈 전자결재 데이터 생성
export function getEmptyElectronicApproval(): ElectronicApproval {
return {
approvers: [],
references: [],
};
}

View File

@@ -0,0 +1,712 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Upload, X, Eye, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { ContractDetail, ContractFormData, ContractAttachment, ContractStatus } from './types';
import {
CONTRACT_STATUS_LABELS,
VAT_TYPE_OPTIONS,
getEmptyContractFormData,
contractDetailToFormData,
} from './types';
import { updateContract, deleteContract } from './actions';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { ContractDocumentModal } from './modals/ContractDocumentModal';
import {
ElectronicApprovalModal,
type ElectronicApproval,
getEmptyElectronicApproval,
} from '../common';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 파일 사이즈 포맷팅
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
interface ContractDetailFormProps {
mode: 'view' | 'edit';
contractId: string;
initialData?: ContractDetail;
}
export default function ContractDetailForm({
mode,
contractId,
initialData,
}: ContractDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
// 폼 데이터
const [formData, setFormData] = useState<ContractFormData>(
initialData ? contractDetailToFormData(initialData) : getEmptyContractFormData()
);
// 기존 첨부파일 (서버에서 가져온 파일)
const [existingAttachments, setExistingAttachments] = useState<ContractAttachment[]>(
initialData?.attachments || []
);
// 새로 추가된 파일
const [newAttachments, setNewAttachments] = useState<File[]>([]);
// 기존 계약서 파일 삭제 여부
const [isContractFileDeleted, setIsContractFileDeleted] = useState(false);
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
// 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
// 모달 상태
const [showDocumentModal, setShowDocumentModal] = useState(false);
const [showApprovalModal, setShowApprovalModal] = useState(false);
// 전자결재 데이터
const [approvalData, setApprovalData] = useState<ElectronicApproval>(
getEmptyElectronicApproval()
);
// 파일 업로드 ref
const contractFileInputRef = useRef<HTMLInputElement>(null);
const attachmentInputRef = useRef<HTMLInputElement>(null);
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
// 네비게이션 핸들러
const handleBack = useCallback(() => {
router.push('/ko/juil/project/contract');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/juil/project/contract/${contractId}/edit`);
}, [router, contractId]);
const handleCancel = useCallback(() => {
router.push(`/ko/juil/project/contract/${contractId}`);
}, [router, contractId]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof ContractFormData, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// 저장 핸들러
const handleSave = useCallback(() => {
setShowSaveDialog(true);
}, []);
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
const result = await updateContract(contractId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/juil/project/contract/${contractId}`);
router.refresh();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, contractId, formData]);
// 삭제 핸들러
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
setIsLoading(true);
try {
const result = await deleteContract(contractId);
if (result.success) {
toast.success('계약이 삭제되었습니다.');
setShowDeleteDialog(false);
router.push('/ko/juil/project/contract');
router.refresh();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, contractId]);
// 계약서 파일 선택
const handleContractFileSelect = useCallback(() => {
contractFileInputRef.current?.click();
}, []);
const handleContractFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.type !== 'application/pdf') {
toast.error('PDF 파일만 업로드 가능합니다.');
return;
}
setFormData((prev) => ({ ...prev, contractFile: file }));
}
},
[]
);
// 첨부 파일 드래그 앤 드롭
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
setNewAttachments((prev) => [...prev, ...files]);
}, []);
const handleAttachmentSelect = useCallback(() => {
attachmentInputRef.current?.click();
}, []);
const handleAttachmentChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
setNewAttachments((prev) => [...prev, ...files]);
},
[]
);
// 기존 첨부파일 삭제
const handleRemoveExistingAttachment = useCallback((id: string) => {
setExistingAttachments((prev) => prev.filter((att) => att.id !== id));
}, []);
// 새 첨부파일 삭제
const handleRemoveNewAttachment = useCallback((index: number) => {
setNewAttachments((prev) => prev.filter((_, i) => i !== index));
}, []);
// 기존 계약서 파일 삭제
const handleRemoveContractFile = useCallback(() => {
setIsContractFileDeleted(true);
setFormData((prev) => ({ ...prev, contractFile: null }));
}, []);
// 계약서 보기 핸들러
const handleViewDocument = useCallback(() => {
setShowDocumentModal(true);
}, []);
// 파일 다운로드 핸들러
const handleFileDownload = useCallback(async (fileId: string, fileName?: string) => {
try {
await downloadFileById(parseInt(fileId), fileName);
} catch (error) {
console.error('[ContractDetailForm] 다운로드 실패:', error);
toast.error('파일 다운로드에 실패했습니다.');
}
}, []);
// 전자결재 핸들러
const handleApproval = useCallback(() => {
setShowApprovalModal(true);
}, []);
// 전자결재 저장
const handleApprovalSave = useCallback((approval: ElectronicApproval) => {
setApprovalData(approval);
setShowApprovalModal(false);
toast.success('전자결재 정보가 저장되었습니다.');
}, []);
// 헤더 액션 버튼
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleViewDocument}>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleApproval}>
</Button>
<Button onClick={handleEdit}></Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
</Button>
</div>
);
return (
<PageLayout>
<PageHeader
title="계약 상세"
description="계약 정보를 관리합니다"
icon={FileText}
onBack={handleBack}
actions={headerActions}
/>
<div className="space-y-6">
{/* 계약 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 계약번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.contractCode}
onChange={(e) => handleFieldChange('contractCode', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약담당자 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.contractManagerName}
onChange={(e) => handleFieldChange('contractManagerName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.partnerName}
onChange={(e) => handleFieldChange('partnerName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.projectName}
onChange={(e) => handleFieldChange('projectName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약일자 */}
<div className="space-y-2">
<Label></Label>
<Input
type="date"
value={formData.contractDate}
onChange={(e) => handleFieldChange('contractDate', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 개소 */}
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={formData.totalLocations}
onChange={(e) => handleFieldChange('totalLocations', parseInt(e.target.value) || 0)}
disabled={isViewMode}
/>
</div>
{/* 계약기간 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Input
type="date"
value={formData.contractStartDate}
onChange={(e) => handleFieldChange('contractStartDate', e.target.value)}
disabled={isViewMode}
/>
<span>~</span>
<Input
type="date"
value={formData.contractEndDate}
onChange={(e) => handleFieldChange('contractEndDate', e.target.value)}
disabled={isViewMode}
/>
</div>
</div>
{/* 부가세 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.vatType}
onValueChange={(value) => handleFieldChange('vatType', value)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{VAT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 계약금액 */}
<div className="space-y-2">
<Label></Label>
<Input
type="text"
value={formatAmount(formData.contractAmount)}
onChange={(e) => {
const value = e.target.value.replace(/[^0-9]/g, '');
handleFieldChange('contractAmount', parseInt(value) || 0);
}}
disabled={isViewMode}
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<RadioGroup
value={formData.status}
onValueChange={(value) => handleFieldChange('status', value as ContractStatus)}
disabled={isViewMode}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pending" id="pending" />
<Label htmlFor="pending" className="font-normal cursor-pointer">
{CONTRACT_STATUS_LABELS.pending}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="completed" id="completed" />
<Label htmlFor="completed" className="font-normal cursor-pointer">
{CONTRACT_STATUS_LABELS.completed}
</Label>
</div>
</RadioGroup>
</div>
{/* 비고 */}
<div className="space-y-2 md:col-span-2">
<Label></Label>
<Textarea
value={formData.remarks}
onChange={(e) => handleFieldChange('remarks', e.target.value)}
disabled={isViewMode}
rows={3}
/>
</div>
</div>
</CardContent>
</Card>
{/* 계약서 관리 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* 파일 선택 버튼 (수정 모드에서만) */}
{isEditMode && (
<Button variant="outline" onClick={handleContractFileSelect}>
</Button>
)}
{/* 새로 선택한 파일 */}
{formData.contractFile && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{formData.contractFile.name}</span>
<span className="text-xs text-blue-600">( )</span>
</div>
{isEditMode && (
<Button
variant="ghost"
size="icon"
onClick={() => setFormData((prev) => ({ ...prev, contractFile: null }))}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)}
{/* 기존 계약서 파일 */}
{!isContractFileDeleted && initialData?.contractFile && !formData.contractFile && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{initialData.contractFile.fileName}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDownload(initialData.contractFile!.id, initialData.contractFile!.fileName)}
>
<Download className="h-4 w-4 mr-1" />
</Button>
{isEditMode && (
<Button
variant="ghost"
size="icon"
onClick={handleRemoveContractFile}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
{/* 파일 없음 안내 */}
{!formData.contractFile && (isContractFileDeleted || !initialData?.contractFile) && (
<span className="text-sm text-muted-foreground">PDF </span>
)}
<input
ref={contractFileInputRef}
type="file"
accept=".pdf"
className="hidden"
onChange={handleContractFileChange}
/>
</div>
</CardContent>
</Card>
{/* 계약 첨부 문서 관리 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
{/* 드래그 앤 드롭 영역 */}
{isEditMode && (
<div
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 transition-colors cursor-pointer ${
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleAttachmentSelect}
>
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-muted-foreground">
, .
</p>
</div>
)}
{/* 파일 목록 */}
<div className="space-y-2">
{/* 기존 첨부파일 */}
{existingAttachments.map((att) => (
<div
key={att.id}
className="flex items-center justify-between p-3 bg-muted rounded-lg"
>
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{att.fileName}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(att.fileSize)}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDownload(att.id, att.fileName)}
>
<Download className="h-4 w-4 mr-1" />
</Button>
{isEditMode && (
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveExistingAttachment(att.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
))}
{/* 새로 추가된 파일 */}
{newAttachments.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-muted rounded-lg"
>
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveNewAttachment(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
<input
ref={attachmentInputRef}
type="file"
multiple
className="hidden"
onChange={handleAttachmentChange}
/>
</CardContent>
</Card>
</div>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 계약서 보기 모달 */}
{initialData && (
<ContractDocumentModal
open={showDocumentModal}
onOpenChange={setShowDocumentModal}
contract={initialData}
/>
)}
{/* 전자결재 모달 */}
<ElectronicApprovalModal
isOpen={showApprovalModal}
onClose={() => setShowApprovalModal(false)}
approval={approvalData}
onSave={handleApprovalSave}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,609 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Contract, ContractStats } from './types';
import {
CONTRACT_STATUS_OPTIONS,
CONTRACT_SORT_OPTIONS,
CONTRACT_STATUS_STYLES,
CONTRACT_STATUS_LABELS,
} from './types';
import { getContractList, getContractStats, deleteContract, deleteContracts } from './actions';
// 테이블 컬럼 정의
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'contractCode', label: '계약번호', className: 'w-[120px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'contractManager', label: '계약담당자', className: 'w-[100px] text-center' },
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px] text-center' },
{ key: 'totalLocations', label: '총 개소', className: 'w-[80px] text-center' },
{ key: 'contractAmount', label: '계약금액', className: 'w-[120px] text-right' },
{ key: 'contractPeriod', label: '계약기간', className: 'w-[180px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 목업 거래처 목록
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: '1', label: '통신공사' },
{ value: '2', label: '야사건설' },
{ value: '3', label: '여의건설' },
];
// 목업 계약담당자 목록
const MOCK_CONTRACT_MANAGERS: MultiSelectOption[] = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
];
// 목업 공사PM 목록
const MOCK_CONSTRUCTION_PMS: MultiSelectOption[] = [
{ value: 'kim', label: '김PM' },
{ value: 'lee', label: '이PM' },
{ value: 'park', label: '박PM' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
}
// 계약기간 포맷팅
function formatPeriod(startDate: string | null, endDate: string | null): string {
const start = formatDate(startDate);
const end = formatDate(endDate);
if (start === '-' && end === '-') return '-';
return `${start} ~ ${end}`;
}
interface ContractListClientProps {
initialData?: Contract[];
initialStats?: ContractStats;
}
export default function ContractListClient({
initialData = [],
initialStats,
}: ContractListClientProps) {
const router = useRouter();
// 상태
const [contracts, setContracts] = useState<Contract[]>(initialData);
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [contractManagerFilters, setContractManagerFilters] = useState<string[]>([]);
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('contractDateDesc');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getContractList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getContractStats(),
]);
if (listResult.success && listResult.data) {
setContracts(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredContracts = useMemo(() => {
return contracts.filter((contract) => {
// 상태 탭 필터
if (activeStatTab === 'pending' && contract.status !== 'pending') return false;
if (activeStatTab === 'completed' && contract.status !== 'completed') return false;
// 거래처 필터
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(contract.partnerId)) return false;
}
// 계약담당자 필터
if (contractManagerFilters.length > 0) {
if (!contractManagerFilters.includes(contract.contractManagerId)) return false;
}
// 공사PM 필터
if (constructionPMFilters.length > 0) {
if (!constructionPMFilters.includes(contract.constructionPMId || '')) return false;
}
// 상태 필터
if (statusFilter !== 'all' && contract.status !== statusFilter) return false;
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
contract.projectName.toLowerCase().includes(search) ||
contract.contractCode.toLowerCase().includes(search) ||
contract.partnerName.toLowerCase().includes(search)
);
}
return true;
});
}, [contracts, activeStatTab, partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, searchValue]);
// 정렬
const sortedContracts = useMemo(() => {
const sorted = [...filteredContracts];
switch (sortBy) {
case 'contractDateDesc':
sorted.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
});
break;
case 'contractDateAsc':
sorted.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
});
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
case 'amountDesc':
sorted.sort((a, b) => b.contractAmount - a.contractAmount);
break;
case 'amountAsc':
sorted.sort((a, b) => a.contractAmount - b.contractAmount);
break;
}
return sorted;
}, [filteredContracts, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedContracts.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedContracts.slice(start, start + itemsPerPage);
}, [sortedContracts, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(contract: Contract) => {
router.push(`/ko/juil/project/contract/${contract.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, contractId: string) => {
e.stopPropagation();
router.push(`/ko/juil/project/contract/${contractId}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, contractId: string) => {
e.stopPropagation();
setDeleteTargetId(contractId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteContract(deleteTargetId);
if (result.success) {
toast.success('계약이 삭제되었습니다.');
setContracts((prev) => prev.filter((c) => c.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteContracts(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
// 테이블 행 렌더링
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
const renderTableRow = useCallback(
(contract: Contract, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(contract.id);
return (
<TableRow
key={contract.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(contract)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(contract.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{contract.contractCode}</TableCell>
<TableCell>{contract.partnerName}</TableCell>
<TableCell>{contract.projectName}</TableCell>
<TableCell className="text-center">{contract.contractManagerName}</TableCell>
<TableCell className="text-center">{contract.constructionPMName || '-'}</TableCell>
<TableCell className="text-center">{contract.totalLocations}</TableCell>
<TableCell className="text-right">{formatAmount(contract.contractAmount)}</TableCell>
<TableCell className="text-center">
{formatPeriod(contract.contractStartDate, contract.contractEndDate)}
</TableCell>
<TableCell className="text-center">
<span className={CONTRACT_STATUS_STYLES[contract.status]}>
{CONTRACT_STATUS_LABELS[contract.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, contract.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, contract.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(contract: Contract, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={contract.projectName}
subtitle={contract.contractCode}
badge={CONTRACT_STATUS_LABELS[contract.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(contract)}
details={[
{ label: '거래처', value: contract.partnerName },
{ label: '총 개소', value: `${contract.totalLocations}` },
{ label: '계약금액', value: `${formatAmount(contract.contractAmount)}` },
{ label: '계약담당자', value: contract.contractManagerName },
{ label: '공사PM', value: contract.constructionPMName || '-' },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜 필터만)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// Stats 카드 데이터 (전체 계약, 계약대기, 계약완료)
const statsCardsData: StatCard[] = [
{
label: '전체 계약',
value: stats?.total ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '계약대기',
value: stats?.pending ?? 0,
icon: Clock,
iconColor: 'text-orange-500',
onClick: () => setActiveStatTab('pending'),
isActive: activeStatTab === 'pending',
},
{
label: '계약완료',
value: stats?.completed ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('completed'),
isActive: activeStatTab === 'completed',
},
];
// 테이블 헤더 액션 (총 건수 + 필터들)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedContracts.length}
</span>
{/* 거래처 필터 */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 계약담당자 필터 */}
<MultiSelectCombobox
options={MOCK_CONTRACT_MANAGERS}
value={contractManagerFilters}
onChange={setContractManagerFilters}
placeholder="계약담당자"
searchPlaceholder="계약담당자 검색..."
className="w-[130px]"
/>
{/* 공사PM 필터 */}
<MultiSelectCombobox
options={MOCK_CONSTRUCTION_PMS}
value={constructionPMFilters}
onChange={setConstructionPMFilters}
placeholder="공사PM"
searchPlaceholder="공사PM 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{CONTRACT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="최신순 (계약일)" />
</SelectTrigger>
<SelectContent>
{CONTRACT_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="계약관리"
description="계약 정보를 관리합니다"
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="계약번호, 거래처, 현장명 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedContracts}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedContracts.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,517 @@
'use server';
import type {
Contract,
ContractDetail,
ContractStats,
ContractStageCount,
ContractListResponse,
ContractFilter,
ContractFormData,
} from './types';
// 목업 데이터
const MOCK_CONTRACTS: Contract[] = [
{
id: '1',
contractCode: 'CT-2025-001',
partnerId: '1',
partnerName: '통신공사',
projectName: '강남역 통신시설 구축',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'kim',
constructionPMName: '김PM',
totalLocations: 15,
contractAmount: 150000000,
contractStartDate: '2025-12-17',
contractEndDate: '2026-06-17',
status: 'pending',
stage: 'estimate_selected',
remarks: '',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
createdBy: 'system',
biddingId: '1',
biddingCode: 'BID-2025-001',
},
{
id: '2',
contractCode: 'CT-2025-002',
partnerId: '2',
partnerName: '야사건설',
projectName: '판교 IT단지 배선공사',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'lee',
constructionPMName: '이PM',
totalLocations: 28,
contractAmount: 280000000,
contractStartDate: '2025-11-01',
contractEndDate: '2026-03-31',
status: 'pending',
stage: 'estimate_progress',
remarks: '',
createdAt: '2025-01-02',
updatedAt: '2025-01-02',
createdBy: 'system',
biddingId: '2',
biddingCode: 'BID-2025-002',
},
{
id: '3',
contractCode: 'CT-2025-003',
partnerId: '3',
partnerName: '여의건설',
projectName: '여의도 오피스빌딩 통신설비',
contractManagerId: 'kim',
contractManagerName: '김철수',
constructionPMId: 'park',
constructionPMName: '박PM',
totalLocations: 42,
contractAmount: 420000000,
contractStartDate: '2025-10-15',
contractEndDate: '2026-04-15',
status: 'pending',
stage: 'delivery',
remarks: '',
createdAt: '2025-01-03',
updatedAt: '2025-01-03',
createdBy: 'system',
biddingId: '3',
biddingCode: 'BID-2025-003',
},
{
id: '4',
contractCode: 'CT-2025-004',
partnerId: '1',
partnerName: '통신공사',
projectName: '송파 데이터센터 증설',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'kim',
constructionPMName: '김PM',
totalLocations: 58,
contractAmount: 580000000,
contractStartDate: '2025-09-01',
contractEndDate: '2026-02-28',
status: 'completed',
stage: 'inspection',
remarks: '',
createdAt: '2025-01-04',
updatedAt: '2025-01-04',
createdBy: 'system',
biddingId: '4',
biddingCode: 'BID-2025-004',
},
{
id: '5',
contractCode: 'CT-2025-005',
partnerId: '2',
partnerName: '야사건설',
projectName: '분당 스마트빌딩 LAN공사',
contractManagerId: 'lee',
contractManagerName: '이영희',
constructionPMId: 'lee',
constructionPMName: '이PM',
totalLocations: 12,
contractAmount: 95000000,
contractStartDate: '2025-12-01',
contractEndDate: '2026-01-31',
status: 'pending',
stage: 'installation',
remarks: '',
createdAt: '2025-01-05',
updatedAt: '2025-01-05',
createdBy: 'system',
biddingId: '5',
biddingCode: 'BID-2025-005',
},
{
id: '6',
contractCode: 'CT-2025-006',
partnerId: '3',
partnerName: '여의건설',
projectName: '마포 복합시설 CCTV설치',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'park',
constructionPMName: '박PM',
totalLocations: 8,
contractAmount: 75000000,
contractStartDate: '2025-08-01',
contractEndDate: '2025-10-31',
status: 'completed',
stage: 'estimate_selected',
remarks: '',
createdAt: '2025-01-06',
updatedAt: '2025-01-06',
createdBy: 'system',
biddingId: '6',
biddingCode: 'BID-2025-006',
},
{
id: '7',
contractCode: 'CT-2025-007',
partnerId: '1',
partnerName: '통신공사',
projectName: '용산 아파트 인터폰교체',
contractManagerId: 'kim',
contractManagerName: '김철수',
constructionPMId: 'kim',
constructionPMName: '김PM',
totalLocations: 120,
contractAmount: 45000000,
contractStartDate: '2025-07-15',
contractEndDate: '2025-09-15',
status: 'completed',
stage: 'estimate_progress',
remarks: '',
createdAt: '2025-01-07',
updatedAt: '2025-01-07',
createdBy: 'system',
biddingId: '7',
biddingCode: 'BID-2025-007',
},
{
id: '8',
contractCode: 'CT-2025-008',
partnerId: '2',
partnerName: '야사건설',
projectName: '성수동 공장 방범설비',
contractManagerId: 'lee',
contractManagerName: '이영희',
constructionPMId: 'lee',
constructionPMName: '이PM',
totalLocations: 24,
contractAmount: 120000000,
contractStartDate: '2025-11-15',
contractEndDate: '2026-02-15',
status: 'pending',
stage: 'other',
remarks: '',
createdAt: '2025-01-08',
updatedAt: '2025-01-08',
createdBy: 'system',
biddingId: '8',
biddingCode: 'BID-2025-008',
},
{
id: '9',
contractCode: 'CT-2025-009',
partnerId: '3',
partnerName: '여의건설',
projectName: '강서 물류센터 네트워크',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'park',
constructionPMName: '박PM',
totalLocations: 35,
contractAmount: 320000000,
contractStartDate: '2025-06-01',
contractEndDate: '2025-11-30',
status: 'completed',
stage: 'inspection',
remarks: '',
createdAt: '2025-01-09',
updatedAt: '2025-01-09',
createdBy: 'system',
biddingId: '9',
biddingCode: 'BID-2025-009',
},
];
// 계약 목록 조회
export async function getContractList(filter?: ContractFilter): Promise<{
success: boolean;
data?: ContractListResponse;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
let filteredData = [...MOCK_CONTRACTS];
// 검색 필터
if (filter?.search) {
const search = filter.search.toLowerCase();
filteredData = filteredData.filter(
(item) =>
item.contractCode.toLowerCase().includes(search) ||
item.partnerName.toLowerCase().includes(search) ||
item.projectName.toLowerCase().includes(search)
);
}
// 상태 필터
if (filter?.status && filter.status !== 'all') {
filteredData = filteredData.filter((item) => item.status === filter.status);
}
// 단계 필터
if (filter?.stage && filter.stage !== 'all') {
filteredData = filteredData.filter((item) => item.stage === filter.stage);
}
// 거래처 필터
if (filter?.partnerId && filter.partnerId !== 'all') {
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
}
// 계약담당자 필터
if (filter?.contractManagerId && filter.contractManagerId !== 'all') {
filteredData = filteredData.filter((item) => item.contractManagerId === filter.contractManagerId);
}
// 공사PM 필터
if (filter?.constructionPMId && filter.constructionPMId !== 'all') {
filteredData = filteredData.filter((item) => item.constructionPMId === filter.constructionPMId);
}
// 날짜 필터
if (filter?.startDate) {
filteredData = filteredData.filter(
(item) => item.contractStartDate && item.contractStartDate >= filter.startDate!
);
}
if (filter?.endDate) {
filteredData = filteredData.filter(
(item) => item.contractEndDate && item.contractEndDate <= filter.endDate!
);
}
// 정렬
const sortBy = filter?.sortBy || 'contractDateDesc';
switch (sortBy) {
case 'contractDateDesc':
filteredData.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
});
break;
case 'contractDateAsc':
filteredData.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
});
break;
case 'partnerNameAsc':
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
case 'amountDesc':
filteredData.sort((a, b) => b.contractAmount - a.contractAmount);
break;
case 'amountAsc':
filteredData.sort((a, b) => a.contractAmount - b.contractAmount);
break;
}
// 페이지네이션
const page = filter?.page || 1;
const size = filter?.size || 20;
const startIndex = (page - 1) * size;
const paginatedData = filteredData.slice(startIndex, startIndex + size);
return {
success: true,
data: {
items: paginatedData,
total: filteredData.length,
page,
size,
totalPages: Math.ceil(filteredData.length / size),
},
};
} catch (error) {
console.error('getContractList error:', error);
return { success: false, error: '계약 목록을 불러오는데 실패했습니다.' };
}
}
// 계약 통계 조회
export async function getContractStats(): Promise<{
success: boolean;
data?: ContractStats;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 100));
const stats: ContractStats = {
total: MOCK_CONTRACTS.length,
pending: MOCK_CONTRACTS.filter((c) => c.status === 'pending').length,
completed: MOCK_CONTRACTS.filter((c) => c.status === 'completed').length,
};
return { success: true, data: stats };
} catch (error) {
console.error('getContractStats error:', error);
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
}
}
// 단계별 건수 조회
export async function getContractStageCounts(): Promise<{
success: boolean;
data?: ContractStageCount;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 100));
const counts: ContractStageCount = {
estimateSelected: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_selected').length,
estimateProgress: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_progress').length,
delivery: MOCK_CONTRACTS.filter((c) => c.stage === 'delivery').length,
installation: MOCK_CONTRACTS.filter((c) => c.stage === 'installation').length,
inspection: MOCK_CONTRACTS.filter((c) => c.stage === 'inspection').length,
other: MOCK_CONTRACTS.filter((c) => c.stage === 'other').length,
};
return { success: true, data: counts };
} catch (error) {
console.error('getContractStageCounts error:', error);
return { success: false, error: '단계별 건수를 불러오는데 실패했습니다.' };
}
}
// 계약 단건 조회
export async function getContract(id: string): Promise<{
success: boolean;
data?: Contract;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 200));
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
if (!contract) {
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
}
return { success: true, data: contract };
} catch (error) {
console.error('getContract error:', error);
return { success: false, error: '계약 정보를 불러오는데 실패했습니다.' };
}
}
// 계약 삭제
export async function deleteContract(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
if (index === -1) {
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
}
return { success: true };
} catch (error) {
console.error('deleteContract error:', error);
return { success: false, error: '계약 삭제에 실패했습니다.' };
}
}
// 계약 일괄 삭제
export async function deleteContracts(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
return { success: true, deletedCount: ids.length };
} catch (error) {
console.error('deleteContracts error:', error);
return { success: false, error: '일괄 삭제에 실패했습니다.' };
}
}
// 계약 상세 조회 (첨부파일 포함)
export async function getContractDetail(id: string): Promise<{
success: boolean;
data?: ContractDetail;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 200));
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
if (!contract) {
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
}
// ContractDetail로 변환 (첨부파일 목데이터 포함)
const contractDetail: ContractDetail = {
...contract,
// 계약서 파일 목업 데이터
contractFile: {
id: '100',
fileName: '계약서_CT-2025-001.pdf',
fileUrl: '/files/contract.pdf',
uploadedAt: contract.createdAt,
},
attachments: [
{
id: 'att-1',
fileName: '견적서.pdf',
fileSize: 1024000,
fileUrl: '/files/estimate.pdf',
uploadedAt: contract.createdAt,
},
{
id: 'att-2',
fileName: '시방서.pdf',
fileSize: 2048000,
fileUrl: '/files/spec.pdf',
uploadedAt: contract.createdAt,
},
],
};
return { success: true, data: contractDetail };
} catch (error) {
console.error('getContractDetail error:', error);
return { success: false, error: '계약 상세 정보를 불러오는데 실패했습니다.' };
}
}
// 계약 수정
export async function updateContract(
id: string,
_data: Partial<ContractFormData>
): Promise<{
success: boolean;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
if (index === -1) {
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
}
// TODO: 실제 API 연동 시 데이터 업데이트 로직
return { success: true };
} catch (error) {
console.error('updateContract error:', error);
return { success: false, error: '계약 수정에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,5 @@
export { default as ContractListClient } from './ContractListClient';
export { default as ContractDetailForm } from './ContractDetailForm';
export * from './types';
export * from './actions';
export * from './modals';

View File

@@ -0,0 +1,104 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Edit,
X as XIcon,
Printer,
Send,
} from 'lucide-react';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ContractDetail } from '../types';
interface ContractDocumentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
contract: ContractDetail;
}
export function ContractDocumentModal({
open,
onOpenChange,
contract,
}: ContractDocumentModalProps) {
// 수정
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
// 상신 (전자결재)
const handleSubmit = () => {
toast.info('전자결재 상신 기능은 준비 중입니다.');
};
// 인쇄
const handlePrint = () => {
printArea({ title: '계약서 인쇄' });
};
// PDF URL 확인
const pdfUrl = contract.contractFile?.fileUrl;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="default" size="sm" onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700">
<Send className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* PDF 뷰어 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
{pdfUrl ? (
<iframe
src={pdfUrl}
className="w-full h-full min-h-[297mm]"
title="계약서 PDF"
/>
) : (
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
<p> .</p>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { ContractDocumentModal } from './ContractDocumentModal';

View File

@@ -0,0 +1,242 @@
/**
* 주일 기업 - 계약관리 타입 정의
*
* 계약 데이터는 낙찰 후 자동 등록됨
*/
// 계약 상태
export type ContractStatus =
| 'pending' // 계약대기
| 'completed'; // 계약완료
// 계약 단계 (스크린샷의 상단 탭)
export type ContractStage =
| 'estimate_selected' // 견적선정
| 'estimate_progress' // 견적진행
| 'delivery' // 납품
| 'installation' // 설치중
| 'inspection' // 검수
| 'other'; // 기타
// 계약 타입
export interface Contract {
id: string;
contractCode: string; // 계약번호
// 기본 정보
partnerId: string; // 거래처 ID
partnerName: string; // 거래처명
projectName: string; // 현장명
// 담당자 정보
contractManagerId: string; // 계약담당자 ID
contractManagerName: string; // 계약담당자
constructionPMId: string; // 공사PM ID
constructionPMName: string; // 공사PM
// 계약 정보
totalLocations: number; // 총 개소
contractAmount: number; // 계약금액
contractStartDate: string | null; // 계약시작일
contractEndDate: string | null; // 계약종료일
// 상태 정보
status: ContractStatus; // 계약상태
stage: ContractStage; // 계약단계
// 비고
remarks: string;
// 메타 정보
createdAt: string;
updatedAt: string;
createdBy: string;
// 연결된 입찰 정보
biddingId: string;
biddingCode: string;
}
// 계약 통계
export interface ContractStats {
total: number; // 전체 계약
pending: number; // 계약대기
completed: number; // 계약완료
}
// 단계별 건수
export interface ContractStageCount {
estimateSelected: number; // 견적선정
estimateProgress: number; // 견적진행
delivery: number; // 납품
installation: number; // 설치중
inspection: number; // 검수
other: number; // 기타
}
// 계약 필터
export interface ContractFilter {
search?: string;
status?: ContractStatus | 'all';
stage?: ContractStage | 'all';
partnerId?: string;
contractManagerId?: string;
constructionPMId?: string;
startDate?: string;
endDate?: string;
sortBy?: string;
page?: number;
size?: number;
}
// API 응답 타입
export interface ContractListResponse {
items: Contract[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 상태 옵션
export const CONTRACT_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'pending', label: '계약대기' },
{ value: 'completed', label: '계약완료' },
];
// 단계 옵션
export const CONTRACT_STAGE_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'estimate_selected', label: '견적선정' },
{ value: 'estimate_progress', label: '견적진행' },
{ value: 'delivery', label: '납품' },
{ value: 'installation', label: '설치중' },
{ value: 'inspection', label: '검수' },
{ value: 'other', label: '기타' },
];
// 정렬 옵션
export const CONTRACT_SORT_OPTIONS = [
{ value: 'contractDateDesc', label: '최신순 (계약일)' },
{ value: 'contractDateAsc', label: '등록순 (계약일)' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'projectNameAsc', label: '현장명 오름차순' },
{ value: 'projectNameDesc', label: '현장명 내림차순' },
{ value: 'amountDesc', label: '계약금액 높은순' },
{ value: 'amountAsc', label: '계약금액 낮은순' },
];
// 상태별 스타일
export const CONTRACT_STATUS_STYLES: Record<ContractStatus, string> = {
pending: 'text-orange-500 font-medium',
completed: 'text-green-600 font-medium',
};
export const CONTRACT_STATUS_LABELS: Record<ContractStatus, string> = {
pending: '계약대기',
completed: '계약완료',
};
// 단계별 라벨
export const CONTRACT_STAGE_LABELS: Record<ContractStage, string> = {
estimate_selected: '견적선정',
estimate_progress: '견적진행',
delivery: '납품',
installation: '설치중',
inspection: '검수',
other: '기타',
};
// 계약 상세 (상세/수정용 확장 타입)
export interface ContractDetail extends Contract {
// 계약서 파일
contractFile?: {
id: string;
fileName: string;
fileUrl: string;
uploadedAt: string;
} | null;
// 첨부 문서 목록
attachments: ContractAttachment[];
}
// 첨부 문서 타입
export interface ContractAttachment {
id: string;
fileName: string;
fileSize: number;
fileUrl: string;
uploadedAt: string;
}
// 계약 폼 데이터
export interface ContractFormData {
contractCode: string;
contractManagerId: string;
contractManagerName: string;
partnerId: string;
partnerName: string;
projectName: string;
contractDate: string;
totalLocations: number;
contractStartDate: string;
contractEndDate: string;
vatType: string;
contractAmount: number;
status: ContractStatus;
remarks: string;
contractFile: File | null;
attachments: File[];
}
// 빈 폼 데이터 생성
export function getEmptyContractFormData(): ContractFormData {
return {
contractCode: '',
contractManagerId: '',
contractManagerName: '',
partnerId: '',
partnerName: '',
projectName: '',
contractDate: '',
totalLocations: 0,
contractStartDate: '',
contractEndDate: '',
vatType: 'excluded',
contractAmount: 0,
status: 'pending',
remarks: '',
contractFile: null,
attachments: [],
};
}
// ContractDetail을 FormData로 변환
export function contractDetailToFormData(detail: ContractDetail): ContractFormData {
return {
contractCode: detail.contractCode,
contractManagerId: detail.contractManagerId,
contractManagerName: detail.contractManagerName,
partnerId: detail.partnerId,
partnerName: detail.partnerName,
projectName: detail.projectName,
contractDate: detail.createdAt,
totalLocations: detail.totalLocations,
contractStartDate: detail.contractStartDate || '',
contractEndDate: detail.contractEndDate || '',
vatType: 'excluded',
contractAmount: detail.contractAmount,
status: detail.status,
remarks: detail.remarks,
contractFile: null,
attachments: [],
};
}
// 부가세 옵션
export const VAT_TYPE_OPTIONS = [
{ value: 'included', label: '부가세 포함' },
{ value: 'excluded', label: '부가세 별도' },
];

View File

@@ -0,0 +1,732 @@
'use client';
import { useState, useCallback, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type {
EstimateDetail,
EstimateDetailFormData,
EstimateSummaryItem,
ExpenseItem,
EstimateDetailItem,
BidDocument,
PriceAdjustmentData,
} from './types';
import { getEmptyEstimateDetailFormData, estimateDetailToFormData } from './types';
import { ElectronicApprovalModal } from './modals/ElectronicApprovalModal';
import { EstimateDocumentModal } from './modals/EstimateDocumentModal';
import { MOCK_MATERIALS, MOCK_EXPENSES } from './utils';
import {
EstimateInfoSection,
EstimateSummarySection,
ExpenseDetailSection,
PriceAdjustmentSection,
EstimateDetailTableSection,
} from './sections';
interface EstimateDetailFormProps {
mode: 'view' | 'edit';
estimateId: string;
initialData?: EstimateDetail;
}
export default function EstimateDetailForm({
mode,
estimateId,
initialData,
}: EstimateDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
// 폼 데이터
const [formData, setFormData] = useState<EstimateDetailFormData>(
initialData ? estimateDetailToFormData(initialData) : getEmptyEstimateDetailFormData()
);
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
// 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
// 모달 상태
const [showApprovalModal, setShowApprovalModal] = useState(false);
const [showDocumentModal, setShowDocumentModal] = useState(false);
// 파일 업로드 ref
const documentInputRef = useRef<HTMLInputElement>(null);
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
// 적용된 조정단가 (전체 적용 버튼 클릭 시 복사됨)
const [appliedPrices, setAppliedPrices] = useState<{
caulking: number;
rail: number;
bottom: number;
boxReinforce: number;
shaft: number;
painting: number;
motor: number;
controller: number;
} | null>(null);
// 조정단가 적용 여부 (전체 적용 버튼 클릭 시에만 true)
const useAdjustedPrice = appliedPrices !== null;
// ===== 네비게이션 핸들러 =====
const handleBack = useCallback(() => {
router.push('/ko/juil/project/bidding/estimates');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/juil/project/bidding/estimates/${estimateId}/edit`);
}, [router, estimateId]);
const handleCancel = useCallback(() => {
router.push(`/ko/juil/project/bidding/estimates/${estimateId}`);
}, [router, estimateId]);
// ===== 저장/삭제 핸들러 =====
const handleSave = useCallback(() => {
setShowSaveDialog(true);
}, []);
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/juil/project/bidding/estimates/${estimateId}`);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, estimateId]);
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
setIsLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('견적이 삭제되었습니다.');
setShowDeleteDialog(false);
router.push('/ko/juil/project/bidding/estimates');
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router]);
// ===== 입찰 정보 핸들러 =====
const handleBidInfoChange = useCallback((field: string, value: string | number) => {
setFormData((prev) => ({
...prev,
bidInfo: { ...prev.bidInfo, [field]: value },
}));
}, []);
// ===== 견적 요약 정보 핸들러 =====
const handleAddSummaryItem = useCallback(() => {
const newItem: EstimateSummaryItem = {
id: String(Date.now()),
name: '',
quantity: 1,
unit: '식',
materialCost: 0,
laborCost: 0,
totalCost: 0,
remarks: '',
};
setFormData((prev) => ({
...prev,
summaryItems: [...prev.summaryItems, newItem],
}));
}, []);
const handleRemoveSummaryItem = useCallback((itemId: string) => {
setFormData((prev) => ({
...prev,
summaryItems: prev.summaryItems.filter((item) => item.id !== itemId),
}));
}, []);
const handleSummaryItemChange = useCallback(
(itemId: string, field: keyof EstimateSummaryItem, value: string | number) => {
setFormData((prev) => ({
...prev,
summaryItems: prev.summaryItems.map((item) => {
if (item.id === itemId) {
const updated = { ...item, [field]: value };
if (field === 'materialCost' || field === 'laborCost') {
updated.totalCost = updated.materialCost + updated.laborCost;
}
return updated;
}
return item;
}),
}));
},
[]
);
const handleSummaryMemoChange = useCallback((memo: string) => {
setFormData((prev) => ({ ...prev, summaryMemo: memo }));
}, []);
// ===== 공과 상세 핸들러 =====
const handleAddExpenseItems = useCallback((count: number) => {
const newItems = Array.from({ length: count }, () => ({
id: String(Date.now() + Math.random()),
name: MOCK_EXPENSES[0]?.value || '',
amount: 100000,
selected: false,
}));
setFormData((prev) => ({
...prev,
expenseItems: [...prev.expenseItems, ...newItems],
}));
}, []);
const handleRemoveSelectedExpenseItems = useCallback(() => {
const selectedIds = formData.expenseItems
.filter((item) => item.selected)
.map((item) => item.id);
setFormData((prev) => ({
...prev,
expenseItems: prev.expenseItems.filter((item) => !selectedIds.includes(item.id)),
}));
}, [formData.expenseItems]);
const handleExpenseItemChange = useCallback(
(itemId: string, field: keyof ExpenseItem, value: string | number) => {
setFormData((prev) => ({
...prev,
expenseItems: prev.expenseItems.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
}));
},
[]
);
const handleExpenseSelectItem = useCallback((id: string, selected: boolean) => {
setFormData((prev) => ({
...prev,
expenseItems: prev.expenseItems.map((item) =>
item.id === id ? { ...item, selected } : item
),
}));
}, []);
const handleExpenseSelectAll = useCallback((selected: boolean) => {
setFormData((prev) => ({
...prev,
expenseItems: prev.expenseItems.map((item) => ({ ...item, selected })),
}));
}, []);
// ===== 품목 단가 조정 핸들러 =====
const handlePriceAdjustmentChange = useCallback(
(key: keyof PriceAdjustmentData, value: number) => {
setFormData((prev) => ({
...prev,
priceAdjustmentData: {
...prev.priceAdjustmentData,
[key]: {
...prev.priceAdjustmentData[key],
adjustedPrice: value,
},
},
}));
},
[]
);
const handlePriceAdjustmentSave = useCallback(() => {
toast.success('단가가 저장되었습니다.');
}, []);
const handlePriceAdjustmentApplyAll = useCallback(() => {
const adjPrices = formData.priceAdjustmentData;
setAppliedPrices({
caulking: adjPrices.caulking.adjustedPrice,
rail: adjPrices.rail.adjustedPrice,
bottom: adjPrices.bottom.adjustedPrice,
boxReinforce: adjPrices.boxReinforce.adjustedPrice,
shaft: adjPrices.shaft.adjustedPrice,
painting: adjPrices.painting.adjustedPrice,
motor: adjPrices.motor.adjustedPrice,
controller: adjPrices.controller.adjustedPrice,
});
toast.success('조정단가가 견적 상세에 적용되었습니다.');
}, [formData.priceAdjustmentData]);
const handlePriceAdjustmentReset = useCallback(() => {
setFormData((prev) => ({
...prev,
priceAdjustmentData: {
caulking: { ...prev.priceAdjustmentData.caulking, adjustedPrice: prev.priceAdjustmentData.caulking.sellingPrice },
rail: { ...prev.priceAdjustmentData.rail, adjustedPrice: prev.priceAdjustmentData.rail.sellingPrice },
bottom: { ...prev.priceAdjustmentData.bottom, adjustedPrice: prev.priceAdjustmentData.bottom.sellingPrice },
boxReinforce: { ...prev.priceAdjustmentData.boxReinforce, adjustedPrice: prev.priceAdjustmentData.boxReinforce.sellingPrice },
shaft: { ...prev.priceAdjustmentData.shaft, adjustedPrice: prev.priceAdjustmentData.shaft.sellingPrice },
painting: { ...prev.priceAdjustmentData.painting, adjustedPrice: prev.priceAdjustmentData.painting.sellingPrice },
motor: { ...prev.priceAdjustmentData.motor, adjustedPrice: prev.priceAdjustmentData.motor.sellingPrice },
controller: { ...prev.priceAdjustmentData.controller, adjustedPrice: prev.priceAdjustmentData.controller.sellingPrice },
},
}));
toast.success('조정단가가 판매단가로 초기화되었습니다.');
}, []);
// ===== 견적 상세 테이블 핸들러 =====
const handleAddDetailItems = useCallback((count: number) => {
const currentLength = formData.detailItems.length;
const newItems: EstimateDetailItem[] = Array.from({ length: count }, (_, i) => ({
id: String(Date.now() + Math.random() + i),
no: currentLength + i + 1,
name: '',
material: MOCK_MATERIALS[0]?.value || '',
width: 0,
height: 0,
quantity: 1,
box: 0,
assembly: 0,
coating: 0,
batting: 0,
mounting: 0,
fitting: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
materialCost: 0,
laborCost: 0,
quantityPrice: 0,
expenseQuantity: 0,
expenseTotal: 0,
totalCost: 0,
otherCost: 0,
marginCost: 0,
totalPrice: 0,
unitPrice: 0,
expense: 0,
marginRate: 1.03,
unitQuantity: 0,
expenseResult: 0,
marginActual: 0,
}));
setFormData((prev) => ({
...prev,
detailItems: [...prev.detailItems, ...newItems],
}));
}, [formData.detailItems.length]);
const handleRemoveDetailItem = useCallback((itemId: string) => {
setFormData((prev) => ({
...prev,
detailItems: prev.detailItems.filter((item) => item.id !== itemId),
}));
}, []);
const handleRemoveSelectedDetailItems = useCallback(() => {
const selectedIds = formData.detailItems
.filter((item) => (item as unknown as { selected?: boolean }).selected)
.map((item) => item.id);
if (selectedIds.length === 0) {
toast.error('삭제할 항목을 선택해주세요.');
return;
}
setFormData((prev) => ({
...prev,
detailItems: prev.detailItems.filter((item) => !selectedIds.includes(item.id)),
}));
toast.success(`${selectedIds.length}건이 삭제되었습니다.`);
}, [formData.detailItems]);
const handleDetailItemChange = useCallback(
(itemId: string, field: keyof EstimateDetailItem, value: string | number) => {
setFormData((prev) => ({
...prev,
detailItems: prev.detailItems.map((item) =>
item.id === itemId ? { ...item, [field]: value } : item
),
}));
},
[]
);
const handleDetailSelectItem = useCallback((id: string, selected: boolean) => {
setFormData((prev) => ({
...prev,
detailItems: prev.detailItems.map((item) =>
item.id === id ? { ...item, selected } as EstimateDetailItem : item
),
}));
}, []);
const handleDetailSelectAll = useCallback((selected: boolean) => {
setFormData((prev) => ({
...prev,
detailItems: prev.detailItems.map((item) => ({ ...item, selected } as EstimateDetailItem)),
}));
}, []);
const handleApplyAdjustedPriceToSelected = useCallback(() => {
const selectedItems = formData.detailItems.filter(
(item) => (item as unknown as { selected?: boolean }).selected
);
if (selectedItems.length === 0) {
toast.error('적용할 항목을 선택해주세요.');
return;
}
const adjustedPrices = formData.priceAdjustmentData;
setFormData((prev) => ({
...prev,
detailItems: prev.detailItems.map((item) => {
if ((item as unknown as { selected?: boolean }).selected) {
return {
...item,
adjustedCaulking: adjustedPrices.caulking.adjustedPrice,
adjustedRail: adjustedPrices.rail.adjustedPrice,
adjustedBottom: adjustedPrices.bottom.adjustedPrice,
adjustedBoxReinforce: adjustedPrices.boxReinforce.adjustedPrice,
adjustedShaft: adjustedPrices.shaft.adjustedPrice,
adjustedPainting: adjustedPrices.painting.adjustedPrice,
adjustedMotor: adjustedPrices.motor.adjustedPrice,
adjustedController: adjustedPrices.controller.adjustedPrice,
};
}
return item;
}),
}));
toast.success(`${selectedItems.length}건에 조정 단가가 적용되었습니다.`);
}, [formData.detailItems, formData.priceAdjustmentData]);
// 견적 상세 초기화: 각 항목의 사용자 수정값(calcXxx)을 초기화하여 자동 계산값으로 복원
const handleDetailReset = useCallback(() => {
setFormData((prev) => ({
...prev,
detailItems: prev.detailItems.map((item) => ({
...item,
selected: false,
// 계산 필드 초기화 (undefined로 설정하면 자동 계산값 사용)
calcWeight: undefined,
calcArea: undefined,
calcSteelScreen: undefined,
calcCaulking: undefined,
calcRail: undefined,
calcBottom: undefined,
calcBoxReinforce: undefined,
calcShaft: undefined,
calcUnitPrice: undefined,
calcExpense: undefined,
} as EstimateDetailItem)),
}));
toast.success('견적 상세가 초기화되었습니다.');
}, []);
// ===== 파일 업로드 핸들러 =====
const handleDocumentUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
toast.error('파일 크기는 10MB 이하여야 합니다.');
return;
}
const doc: BidDocument = {
id: String(Date.now()),
fileName: file.name,
fileUrl: URL.createObjectURL(file),
fileSize: file.size,
};
setFormData((prev) => ({
...prev,
bidInfo: {
...prev.bidInfo,
documents: [...prev.bidInfo.documents, doc],
},
}));
if (documentInputRef.current) {
documentInputRef.current.value = '';
}
}, []);
const handleDocumentRemove = useCallback((docId: string) => {
setFormData((prev) => ({
...prev,
bidInfo: {
...prev.bidInfo,
documents: prev.bidInfo.documents.filter((d) => d.id !== docId),
},
}));
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!isViewMode) {
setIsDragging(true);
}
},
[isViewMode]
);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (isViewMode) return;
const files = Array.from(e.dataTransfer.files);
files.forEach((file) => {
if (file.size > 10 * 1024 * 1024) {
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
return;
}
const doc: BidDocument = {
id: String(Date.now() + Math.random()),
fileName: file.name,
fileUrl: URL.createObjectURL(file),
fileSize: file.size,
};
setFormData((prev) => ({
...prev,
bidInfo: {
...prev.bidInfo,
documents: [...prev.bidInfo.documents, doc],
},
}));
});
},
[isViewMode]
);
// ===== 타이틀 및 설명 =====
const pageTitle = useMemo(() => {
return isEditMode ? '견적 수정' : '견적 상세';
}, [isEditMode]);
const pageDescription = useMemo(() => {
return isEditMode ? '견적 정보를 수정합니다' : '견적 정보를 등록하고 관리합니다';
}, [isEditMode]);
// ===== 헤더 버튼 =====
const headerActions = useMemo(() => {
if (isViewMode) {
return (
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowDocumentModal(true)}>
</Button>
<Button variant="outline" onClick={() => setShowApprovalModal(true)}>
</Button>
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
</Button>
</div>
);
}
return (
<div className="flex gap-2">
<Button variant="outline" onClick={handleBack}>
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={handleDelete}
>
</Button>
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</div>
);
}, [isViewMode, isLoading, handleBack, handleEdit, handleDelete, handleSave]);
return (
<PageLayout>
<PageHeader
title={pageTitle}
description={pageDescription}
icon={FileText}
actions={headerActions}
onBack={handleBack}
/>
<div className="space-y-8">
{/* 견적 정보 + 현장설명회 + 입찰 정보 */}
<EstimateInfoSection
formData={formData}
isViewMode={isViewMode}
isDragging={isDragging}
documentInputRef={documentInputRef}
onFormDataChange={(updates) => setFormData((prev) => ({ ...prev, ...updates }))}
onBidInfoChange={handleBidInfoChange}
onDocumentUpload={handleDocumentUpload}
onDocumentRemove={handleDocumentRemove}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
/>
{/* 견적 요약 정보 */}
<EstimateSummarySection
summaryItems={formData.summaryItems}
summaryMemo={formData.summaryMemo}
isViewMode={isViewMode}
onAddItem={handleAddSummaryItem}
onRemoveItem={handleRemoveSummaryItem}
onItemChange={handleSummaryItemChange}
onMemoChange={handleSummaryMemoChange}
/>
{/* 공과 상세 */}
<ExpenseDetailSection
expenseItems={formData.expenseItems}
isViewMode={isViewMode}
onAddItems={handleAddExpenseItems}
onRemoveSelected={handleRemoveSelectedExpenseItems}
onItemChange={handleExpenseItemChange}
onSelectItem={handleExpenseSelectItem}
onSelectAll={handleExpenseSelectAll}
/>
{/* 품목 단가 조정 */}
<PriceAdjustmentSection
priceAdjustmentData={formData.priceAdjustmentData}
isViewMode={isViewMode}
onPriceChange={handlePriceAdjustmentChange}
onSave={handlePriceAdjustmentSave}
onApplyAll={handlePriceAdjustmentApplyAll}
onReset={handlePriceAdjustmentReset}
/>
{/* 견적 상세 테이블 */}
<EstimateDetailTableSection
detailItems={formData.detailItems}
appliedPrices={appliedPrices}
isViewMode={isViewMode}
onAddItems={handleAddDetailItems}
onRemoveItem={handleRemoveDetailItem}
onRemoveSelected={handleRemoveSelectedDetailItems}
onItemChange={handleDetailItemChange}
onSelectItem={handleDetailSelectItem}
onSelectAll={handleDetailSelectAll}
onApplyAdjustedPrice={handleApplyAdjustedPriceToSelected}
onReset={handleDetailReset}
/>
</div>
{/* 전자결재 모달 */}
<ElectronicApprovalModal
isOpen={showApprovalModal}
onClose={() => setShowApprovalModal(false)}
approval={formData.approval}
onSave={(approval) => {
setFormData((prev) => ({ ...prev, approval }));
setShowApprovalModal(false);
toast.success('결재선이 저장되었습니다.');
}}
/>
{/* 견적서 모달 */}
<EstimateDocumentModal
isOpen={showDocumentModal}
onClose={() => setShowDocumentModal(false)}
formData={formData}
estimateId={estimateId}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-orange-500 hover:bg-orange-600"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, FileTextIcon, FilePenLine, FileCheck, Plus, Pencil, Trash2 } from 'lucide-react';
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -13,6 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
@@ -33,8 +34,6 @@ import {
ESTIMATE_SORT_OPTIONS,
STATUS_STYLES,
STATUS_LABELS,
AWARD_STATUS_LABELS,
AWARD_STATUS_STYLES,
} from './types';
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates } from './actions';
@@ -45,26 +44,23 @@ const tableColumns: TableColumn[] = [
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'estimatorName', label: '견적자', className: 'w-[80px] text-center' },
{ key: 'itemCount', label: '', className: 'w-[60px] text-center' },
{ key: 'itemCount', label: '총 개소', className: 'w-[80px] text-center' },
{ key: 'estimateAmount', label: '견적금액', className: 'w-[120px] text-right' },
{ key: 'distributionDate', label: '견적배부일', className: 'w-[110px] text-center' },
{ key: 'completedDate', label: '견적완료일', className: 'w-[110px] text-center' },
{ key: 'bidDate', label: '입찰일', className: 'w-[110px] text-center' },
{ key: 'awardStatus', label: '낙찰', className: 'w-[70px] text-center' },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 목업 거래처 목록
const MOCK_PARTNERS = [
{ value: 'all', label: '전체' },
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: '1', label: '회사명' },
{ value: '2', label: '야사 대림아파트' },
{ value: '3', label: '여의 현장아파트' },
];
// 목업 견적자 목록
const MOCK_ESTIMATORS = [
{ value: 'all', label: '전체' },
// 목업 견적자 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_ESTIMATORS: MultiSelectOption[] = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
@@ -87,8 +83,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
const [estimates, setEstimates] = useState<Estimate[]>(initialData);
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [partnerFilter, setPartnerFilter] = useState<string>('all');
const [estimatorFilter, setEstimatorFilter] = useState<string>('all');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [estimatorFilters, setEstimatorFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
@@ -99,7 +95,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'drafting' | 'completed'>('all');
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const itemsPerPage = 20;
// 데이터 로드
@@ -139,14 +135,18 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
const filteredEstimates = useMemo(() => {
return estimates.filter((estimate) => {
// 상태 탭 필터
if (activeStatTab === 'drafting' && estimate.status !== 'drafting') return false;
if (activeStatTab === 'pending' && estimate.status !== 'pending') return false;
if (activeStatTab === 'completed' && estimate.status !== 'completed') return false;
// 거래처 필터
if (partnerFilter !== 'all' && estimate.partnerId !== partnerFilter) return false;
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(estimate.partnerId)) return false;
}
// 견적자 필터
if (estimatorFilter !== 'all' && estimate.estimatorId !== estimatorFilter) return false;
// 견적자 필터 (다중선택 - 빈 배열 = 전체)
if (estimatorFilters.length > 0) {
if (!estimatorFilters.includes(estimate.estimatorId)) return false;
}
// 상태 필터
if (statusFilter !== 'all' && estimate.status !== statusFilter) return false;
@@ -162,23 +162,25 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
}
return true;
});
}, [estimates, activeStatTab, partnerFilter, estimatorFilter, statusFilter, searchValue]);
}, [estimates, activeStatTab, partnerFilters, estimatorFilters, statusFilter, searchValue]);
// 정렬
const sortedEstimates = useMemo(() => {
const sorted = [...filteredEstimates];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
sorted.sort((a, b) => {
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
return dateB - dateA;
});
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'amountDesc':
sorted.sort((a, b) => b.estimateAmount - a.estimateAmount);
break;
case 'amountAsc':
sorted.sort((a, b) => a.estimateAmount - b.estimateAmount);
sorted.sort((a, b) => {
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
return dateA - dateB;
});
break;
case 'bidDateDesc':
sorted.sort((a, b) => {
@@ -187,6 +189,18 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
});
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
}
return sorted;
}, [filteredEstimates, sortBy]);
@@ -231,10 +245,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
[router]
);
const handleCreate = useCallback(() => {
router.push('/ko/juil/project/bidding/estimates/new');
}, [router]);
const handleEdit = useCallback(
(e: React.MouseEvent, estimateId: string) => {
e.stopPropagation();
@@ -329,13 +339,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
<TableCell className="text-center">{estimate.estimatorName}</TableCell>
<TableCell className="text-center">{estimate.itemCount}</TableCell>
<TableCell className="text-right">{formatAmount(estimate.estimateAmount)}</TableCell>
<TableCell className="text-center">{estimate.distributionDate || '-'}</TableCell>
<TableCell className="text-center">{estimate.completedDate || '-'}</TableCell>
<TableCell className="text-center">{estimate.bidDate || '-'}</TableCell>
<TableCell className="text-center">
<span className={AWARD_STATUS_STYLES[estimate.awardStatus]}>
{AWARD_STATUS_LABELS[estimate.awardStatus]}
</span>
</TableCell>
<TableCell className="text-center">
<span className={STATUS_STYLES[estimate.status]}>
{STATUS_LABELS[estimate.status]}
@@ -352,21 +357,13 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, estimate.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
);
// 모바일 카드 렌더링
@@ -393,26 +390,20 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
[handleRowClick]
);
// 헤더 액션 (등록 버튼 + 날짜 필터)
// 헤더 액션 (날짜 필터만 - 견적등록은 현장설명회 참석완료 시 자동 등록)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
}
/>
);
// Stats 카드 데이터 (StatCards 컴포넌트용)
const statsCardsData: StatCard[] = [
{
label: '전체',
label: '전체 견적',
value: stats?.total ?? 0,
icon: FileTextIcon,
iconColor: 'text-blue-600',
@@ -420,12 +411,12 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
isActive: activeStatTab === 'all',
},
{
label: '견적작성중',
value: stats?.drafting ?? 0,
icon: FilePenLine,
label: '견적대기',
value: stats?.pending ?? 0,
icon: Clock,
iconColor: 'text-orange-500',
onClick: () => setActiveStatTab('drafting'),
isActive: activeStatTab === 'drafting',
onClick: () => setActiveStatTab('pending'),
isActive: activeStatTab === 'pending',
},
{
label: '견적완료',
@@ -444,33 +435,25 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
{sortedEstimates.length}
</span>
{/* 거래처 필터 */}
<Select value={partnerFilter} onValueChange={setPartnerFilter}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="거래처" />
</SelectTrigger>
<SelectContent>
{MOCK_PARTNERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 견적자 필터 */}
<Select value={estimatorFilter} onValueChange={setEstimatorFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="견적자" />
</SelectTrigger>
<SelectContent>
{MOCK_ESTIMATORS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 견적자 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_ESTIMATORS}
value={estimatorFilters}
onChange={setEstimatorFilters}
placeholder="견적자"
searchPlaceholder="견적자 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
@@ -523,7 +506,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,

View File

@@ -19,10 +19,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 8,
estimateAmount: 100000000,
distributionDate: '2025-12-15',
completedDate: null,
bidDate: '2025-12-15',
status: 'drafting',
awardStatus: 'pending',
status: 'pending',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
createdBy: '홍길동',
@@ -37,10 +36,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 8,
estimateAmount: 100000000,
distributionDate: '2025-12-15',
completedDate: null,
bidDate: '2025-12-15',
status: 'drafting',
awardStatus: 'pending',
status: 'pending',
createdAt: '2025-01-02',
updatedAt: '2025-01-02',
createdBy: '홍길동',
@@ -55,10 +53,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 21,
estimateAmount: 50000000,
distributionDate: '2025-12-15',
completedDate: null,
bidDate: '2025-12-15',
status: 'drafting',
awardStatus: 'pending',
status: 'pending',
createdAt: '2025-01-03',
updatedAt: '2025-01-03',
createdBy: '홍길동',
@@ -73,10 +70,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 0,
estimateAmount: 10000000,
distributionDate: null,
completedDate: '2025-12-10',
bidDate: '2025-12-15',
status: 'completed',
awardStatus: 'pending',
createdAt: '2025-01-04',
updatedAt: '2025-01-04',
createdBy: '홍길동',
@@ -91,10 +87,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 0,
estimateAmount: 10000000,
distributionDate: null,
completedDate: '2025-12-11',
bidDate: '2025-12-15',
status: 'completed',
awardStatus: 'pending',
createdAt: '2025-01-05',
updatedAt: '2025-01-05',
createdBy: '홍길동',
@@ -109,10 +104,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 0,
estimateAmount: 10000000,
distributionDate: null,
completedDate: '2025-12-12',
bidDate: '2025-12-15',
status: 'completed',
awardStatus: 'awarded',
createdAt: '2025-01-06',
updatedAt: '2025-01-06',
createdBy: '홍길동',
@@ -127,10 +121,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '김철수',
itemCount: 15,
estimateAmount: 200000000,
distributionDate: '2025-12-18',
completedDate: null,
bidDate: '2025-12-20',
status: 'drafting',
awardStatus: 'pending',
status: 'pending',
createdAt: '2025-01-07',
updatedAt: '2025-01-07',
createdBy: '김철수',
@@ -246,17 +239,15 @@ export async function getEstimate(
export async function getEstimateStats(): Promise<{ success: boolean; data?: EstimateStats; error?: string }> {
try {
const total = mockEstimates.length;
const drafting = mockEstimates.filter((e) => e.status === 'drafting').length;
const pending = mockEstimates.filter((e) => e.status === 'pending').length;
const completed = mockEstimates.filter((e) => e.status === 'completed').length;
const awarded = mockEstimates.filter((e) => e.awardStatus === 'awarded').length;
return {
success: true,
data: {
total,
drafting,
pending,
completed,
awarded,
},
};
} catch (error) {

View File

@@ -0,0 +1 @@
export * from './useEstimateCalculations';

View File

@@ -0,0 +1,502 @@
import { useMemo } from 'react';
import type { EstimateDetailItem, PriceAdjustmentData } from '../types';
interface CalculatedValues {
area: number;
weight: number;
steelScreen: number;
caulking: number;
rail: number;
bottom: number;
boxReinforce: number;
shaft: number;
painting: number;
motor: number;
controller: number;
widthConst: number;
heightConst: number;
unitPrice: number;
expenseRate: number;
expense: number;
quantity: number;
cost: number;
costExecution: number;
marginCost: number;
marginCostExecution: number;
expenseExecution: number;
}
// appliedPrices 타입 (전체 적용 시 복사된 가격)
export interface AppliedPrices {
caulking: number;
rail: number;
bottom: number;
boxReinforce: number;
shaft: number;
painting: number;
motor: number;
controller: number;
}
// 기본 단가 상수
const DEFAULT_PRICES = {
caulking: 9500,
rail: 85000,
bottom: 113000,
boxReinforce: 11000,
shaft: 14000,
painting: 0,
motor: 0,
controller: 0,
};
/**
* 기본 계산값 생성 (가로/세로 기반)
* 사용자가 수정하지 않았을 때 사용되는 자동 계산 값
*/
export function getDefaultCalculatedValues(
width: number,
height: number,
priceAdjustmentData: PriceAdjustmentData,
useAdjustedPrice: boolean
) {
const caulkingPrice = useAdjustedPrice ? priceAdjustmentData.caulking.adjustedPrice : DEFAULT_PRICES.caulking;
const railPrice = useAdjustedPrice ? priceAdjustmentData.rail.adjustedPrice : DEFAULT_PRICES.rail;
const bottomPrice = useAdjustedPrice ? priceAdjustmentData.bottom.adjustedPrice : DEFAULT_PRICES.bottom;
const boxReinforcePrice = useAdjustedPrice ? priceAdjustmentData.boxReinforce.adjustedPrice : DEFAULT_PRICES.boxReinforce;
const shaftPrice = useAdjustedPrice ? priceAdjustmentData.shaft.adjustedPrice : DEFAULT_PRICES.shaft;
// 06. 면적: (가로*0.16)*(세로*0.5)
const area = (width * 0.16) * (height * 0.5);
// 05. 무게: 면적*25
const weight = area * 25;
// 07. 철제,스크린: 면적*47500
const steelScreen = Math.round(area * 47500);
// 08. 코킹: (세로*4)*조정단가
const caulking = Math.round((height * 4) * caulkingPrice);
// 09. 레일: (세로*0.2)*조정단가
const rail = Math.round((height * 0.2) * railPrice);
// 10. 하장: 가로*조정단가
const bottom = Math.round(width * bottomPrice);
// 11. 박스+보강: 가로*조정단가
const boxReinforce = Math.round(width * boxReinforcePrice);
// 12. 샤프트: 가로*조정단가
const shaft = Math.round(width * shaftPrice);
return { area, weight, steelScreen, caulking, rail, bottom, boxReinforce, shaft };
}
/**
* 개별 견적 상세 항목의 계산값 반환
* calcXxx 필드가 있으면 사용자 입력값, 없으면 자동 계산값 사용
*/
export function calculateItemValues(
item: EstimateDetailItem,
priceAdjustmentData: PriceAdjustmentData,
useAdjustedPrice: boolean
): CalculatedValues {
// 조정단가 사용 여부에 따른 단가 선택
const paintingPrice = useAdjustedPrice ? priceAdjustmentData.painting.adjustedPrice : DEFAULT_PRICES.painting;
const motorPrice = useAdjustedPrice ? priceAdjustmentData.motor.adjustedPrice : DEFAULT_PRICES.motor;
const controllerPrice = useAdjustedPrice ? priceAdjustmentData.controller.adjustedPrice : DEFAULT_PRICES.controller;
// 기본 계산값 가져오기
const defaultCalc = getDefaultCalculatedValues(
item.width,
item.height,
priceAdjustmentData,
useAdjustedPrice
);
// 사용자 입력값이 있으면 사용, 없으면 자동 계산값 사용
const area = item.calcArea ?? defaultCalc.area;
const weight = item.calcWeight ?? defaultCalc.weight;
const steelScreen = item.calcSteelScreen ?? defaultCalc.steelScreen;
const caulking = item.calcCaulking ?? defaultCalc.caulking;
const rail = item.calcRail ?? defaultCalc.rail;
const bottom = item.calcBottom ?? defaultCalc.bottom;
const boxReinforce = item.calcBoxReinforce ?? defaultCalc.boxReinforce;
const shaft = item.calcShaft ?? defaultCalc.shaft;
// 13~17: 셀렉트박스 (도장, 모터, 제어기, 가로시공비, 세로시공비)
// 조정단가 적용 시 셀렉트 값 대신 조정단가 사용
const painting = useAdjustedPrice && paintingPrice > 0 ? paintingPrice : (item.coating || 0);
const motor = useAdjustedPrice && motorPrice > 0 ? motorPrice : (item.mounting || 0);
const controller = useAdjustedPrice && controllerPrice > 0 ? controllerPrice : (item.controller || 0);
const widthConst = item.widthConstruction || 0;
const heightConst = item.heightConstruction || 0;
// 18. 단가: (7)~(17)의 합 - 사용자 입력값이 있으면 사용
const calculatedUnitPrice = steelScreen + caulking + rail + bottom + boxReinforce + shaft + painting + motor + controller + widthConst + heightConst;
const unitPrice = item.calcUnitPrice ?? calculatedUnitPrice;
// 19. 공과율: 입력값
const expenseRate = item.expense || 0;
// 20. 공과: 단가*공과율 - 사용자 입력값이 있으면 사용
const calculatedExpense = Math.round(unitPrice * expenseRate);
const expense = item.calcExpense ?? calculatedExpense;
// 21. 수량
const quantity = item.quantity || 1;
// 22. 원가: 단가+공과*수량
const cost = Math.round((unitPrice + expense) * quantity);
// 23. 원가실행: (단가+공과)*수량
const costExecution = Math.round((unitPrice + expense) * quantity);
// 24. 마진원가: 단가*공과율*1.03
const marginCost = Math.round(unitPrice * expenseRate * 1.03);
// 25. 마진원가실행: 마진원가*수량
const marginCostExecution = Math.round(marginCost * quantity);
// 26. 공과실행: 공과*수량
const expenseExecution = Math.round(expense * quantity);
return {
area,
weight,
steelScreen,
caulking,
rail,
bottom,
boxReinforce,
shaft,
painting,
motor,
controller,
widthConst,
heightConst,
unitPrice,
expenseRate,
expense,
quantity,
cost,
costExecution,
marginCost,
marginCostExecution,
expenseExecution,
};
}
interface TotalValues {
weight: number;
area: number;
steelScreen: number;
caulking: number;
rail: number;
bottom: number;
boxReinforce: number;
shaft: number;
painting: number;
motor: number;
controller: number;
widthConstruction: number;
heightConstruction: number;
unitPrice: number;
expense: number;
quantity: number;
cost: number;
costExecution: number;
marginCost: number;
marginCostExecution: number;
expenseExecution: number;
}
/**
* 모든 견적 상세 항목의 합계 계산
*/
export function calculateTotals(
items: EstimateDetailItem[],
priceAdjustmentData: PriceAdjustmentData,
useAdjustedPrice: boolean
): TotalValues {
return items.reduce(
(acc, item) => {
const values = calculateItemValues(item, priceAdjustmentData, useAdjustedPrice);
return {
weight: acc.weight + values.weight,
area: acc.area + values.area,
steelScreen: acc.steelScreen + values.steelScreen,
caulking: acc.caulking + values.caulking,
rail: acc.rail + values.rail,
bottom: acc.bottom + values.bottom,
boxReinforce: acc.boxReinforce + values.boxReinforce,
shaft: acc.shaft + values.shaft,
painting: acc.painting + values.painting,
motor: acc.motor + values.motor,
controller: acc.controller + values.controller,
widthConstruction: acc.widthConstruction + values.widthConst,
heightConstruction: acc.heightConstruction + values.heightConst,
unitPrice: acc.unitPrice + values.unitPrice,
expense: acc.expense + values.expense,
quantity: acc.quantity + values.quantity,
cost: acc.cost + values.cost,
costExecution: acc.costExecution + values.costExecution,
marginCost: acc.marginCost + values.marginCost,
marginCostExecution: acc.marginCostExecution + values.marginCostExecution,
expenseExecution: acc.expenseExecution + values.expenseExecution,
};
},
{
weight: 0,
area: 0,
steelScreen: 0,
caulking: 0,
rail: 0,
bottom: 0,
boxReinforce: 0,
shaft: 0,
painting: 0,
motor: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
unitPrice: 0,
expense: 0,
quantity: 0,
cost: 0,
costExecution: 0,
marginCost: 0,
marginCostExecution: 0,
expenseExecution: 0,
}
);
}
/**
* 견적 상세 테이블용 계산 훅
*/
export function useEstimateCalculations(
items: EstimateDetailItem[],
priceAdjustmentData: PriceAdjustmentData,
useAdjustedPrice: boolean
) {
const totals = useMemo(
() => calculateTotals(items, priceAdjustmentData, useAdjustedPrice),
[items, priceAdjustmentData, useAdjustedPrice]
);
return { totals, calculateItemValues };
}
// ============================================================
// appliedPrices 기반 계산 함수들 (priceAdjustmentData와 독립적)
// "전체 적용" 버튼 클릭 시점에 복사된 가격만 사용
// ============================================================
/**
* 기본 계산값 생성 (개별 항목 조정단가 → appliedPrices → 기본단가 순으로 적용)
*
* 우선순위:
* 1. 개별 항목의 adjustedXxx (선택 적용 버튼으로 설정)
* 2. appliedPrices (전체 적용 버튼으로 설정)
* 3. DEFAULT_PRICES (기본값)
*/
export function getDefaultCalculatedValuesWithApplied(
width: number,
height: number,
appliedPrices: AppliedPrices | null,
itemAdjusted?: {
caulking?: number;
rail?: number;
bottom?: number;
boxReinforce?: number;
shaft?: number;
}
) {
// 우선순위: 개별 항목 조정단가 → 전체 적용 단가 → 기본 단가
const caulkingPrice = itemAdjusted?.caulking ?? appliedPrices?.caulking ?? DEFAULT_PRICES.caulking;
const railPrice = itemAdjusted?.rail ?? appliedPrices?.rail ?? DEFAULT_PRICES.rail;
const bottomPrice = itemAdjusted?.bottom ?? appliedPrices?.bottom ?? DEFAULT_PRICES.bottom;
const boxReinforcePrice = itemAdjusted?.boxReinforce ?? appliedPrices?.boxReinforce ?? DEFAULT_PRICES.boxReinforce;
const shaftPrice = itemAdjusted?.shaft ?? appliedPrices?.shaft ?? DEFAULT_PRICES.shaft;
// 06. 면적: (가로*0.16)*(세로*0.5)
const area = (width * 0.16) * (height * 0.5);
// 05. 무게: 면적*25
const weight = area * 25;
// 07. 철제,스크린: 면적*47500
const steelScreen = Math.round(area * 47500);
// 08. 코킹: (세로*4)*조정단가
const caulking = Math.round((height * 4) * caulkingPrice);
// 09. 레일: (세로*0.2)*조정단가
const rail = Math.round((height * 0.2) * railPrice);
// 10. 하장: 가로*조정단가
const bottom = Math.round(width * bottomPrice);
// 11. 박스+보강: 가로*조정단가
const boxReinforce = Math.round(width * boxReinforcePrice);
// 12. 샤프트: 가로*조정단가
const shaft = Math.round(width * shaftPrice);
return { area, weight, steelScreen, caulking, rail, bottom, boxReinforce, shaft };
}
/**
* 개별 견적 상세 항목의 계산값 반환 (appliedPrices 기반)
*
* 우선순위:
* 1. 개별 항목의 adjustedXxx (선택 적용 버튼으로 설정)
* 2. appliedPrices (전체 적용 버튼으로 설정)
* 3. DEFAULT_PRICES (기본값)
*/
export function calculateItemValuesWithApplied(
item: EstimateDetailItem,
appliedPrices: AppliedPrices | null
): CalculatedValues {
// 개별 항목의 조정단가 (선택 적용 시 설정됨)
const itemAdjusted = {
caulking: item.adjustedCaulking,
rail: item.adjustedRail,
bottom: item.adjustedBottom,
boxReinforce: item.adjustedBoxReinforce,
shaft: item.adjustedShaft,
};
// 우선순위: 개별 항목 조정단가 → 전체 적용 단가 → 기본 단가
const paintingPrice = item.adjustedPainting ?? appliedPrices?.painting ?? DEFAULT_PRICES.painting;
const motorPrice = item.adjustedMotor ?? appliedPrices?.motor ?? DEFAULT_PRICES.motor;
const controllerPrice = item.adjustedController ?? appliedPrices?.controller ?? DEFAULT_PRICES.controller;
// 기본 계산값 가져오기 (개별 조정단가 우선 적용)
const defaultCalc = getDefaultCalculatedValuesWithApplied(
item.width,
item.height,
appliedPrices,
itemAdjusted
);
// 사용자 입력값이 있으면 사용, 없으면 자동 계산값 사용
const area = item.calcArea ?? defaultCalc.area;
const weight = item.calcWeight ?? defaultCalc.weight;
const steelScreen = item.calcSteelScreen ?? defaultCalc.steelScreen;
const caulking = item.calcCaulking ?? defaultCalc.caulking;
const rail = item.calcRail ?? defaultCalc.rail;
const bottom = item.calcBottom ?? defaultCalc.bottom;
const boxReinforce = item.calcBoxReinforce ?? defaultCalc.boxReinforce;
const shaft = item.calcShaft ?? defaultCalc.shaft;
// 13~17: 셀렉트박스 (도장, 모터, 제어기, 가로시공비, 세로시공비)
// 우선순위: 개별 조정단가 → 전체 적용 단가 → 셀렉트박스 값
const hasItemPainting = item.adjustedPainting !== undefined;
const hasAppliedPainting = appliedPrices && appliedPrices.painting > 0;
const painting = hasItemPainting ? paintingPrice : (hasAppliedPainting ? paintingPrice : (item.coating || 0));
const hasItemMotor = item.adjustedMotor !== undefined;
const hasAppliedMotor = appliedPrices && appliedPrices.motor > 0;
const motor = hasItemMotor ? motorPrice : (hasAppliedMotor ? motorPrice : (item.mounting || 0));
const hasItemController = item.adjustedController !== undefined;
const hasAppliedController = appliedPrices && appliedPrices.controller > 0;
const controller = hasItemController ? controllerPrice : (hasAppliedController ? controllerPrice : (item.controller || 0));
const widthConst = item.widthConstruction || 0;
const heightConst = item.heightConstruction || 0;
// 18. 단가: (7)~(17)의 합 - 사용자 입력값이 있으면 사용
const calculatedUnitPrice = steelScreen + caulking + rail + bottom + boxReinforce + shaft + painting + motor + controller + widthConst + heightConst;
const unitPrice = item.calcUnitPrice ?? calculatedUnitPrice;
// 19. 공과율: 입력값
const expenseRate = item.expense || 0;
// 20. 공과: 단가*공과율 - 사용자 입력값이 있으면 사용
const calculatedExpense = Math.round(unitPrice * expenseRate);
const expense = item.calcExpense ?? calculatedExpense;
// 21. 수량
const quantity = item.quantity || 1;
// 22. 원가: 단가+공과*수량
const cost = Math.round((unitPrice + expense) * quantity);
// 23. 원가실행: (단가+공과)*수량
const costExecution = Math.round((unitPrice + expense) * quantity);
// 24. 마진원가: 단가*공과율*1.03
const marginCost = Math.round(unitPrice * expenseRate * 1.03);
// 25. 마진원가실행: 마진원가*수량
const marginCostExecution = Math.round(marginCost * quantity);
// 26. 공과실행: 공과*수량
const expenseExecution = Math.round(expense * quantity);
return {
area,
weight,
steelScreen,
caulking,
rail,
bottom,
boxReinforce,
shaft,
painting,
motor,
controller,
widthConst,
heightConst,
unitPrice,
expenseRate,
expense,
quantity,
cost,
costExecution,
marginCost,
marginCostExecution,
expenseExecution,
};
}
/**
* 모든 견적 상세 항목의 합계 계산 (appliedPrices 기반)
* priceAdjustmentData 변경에 영향받지 않음
*/
export function calculateTotalsWithApplied(
items: EstimateDetailItem[],
appliedPrices: AppliedPrices | null
): TotalValues {
return items.reduce(
(acc, item) => {
const values = calculateItemValuesWithApplied(item, appliedPrices);
return {
weight: acc.weight + values.weight,
area: acc.area + values.area,
steelScreen: acc.steelScreen + values.steelScreen,
caulking: acc.caulking + values.caulking,
rail: acc.rail + values.rail,
bottom: acc.bottom + values.bottom,
boxReinforce: acc.boxReinforce + values.boxReinforce,
shaft: acc.shaft + values.shaft,
painting: acc.painting + values.painting,
motor: acc.motor + values.motor,
controller: acc.controller + values.controller,
widthConstruction: acc.widthConstruction + values.widthConst,
heightConstruction: acc.heightConstruction + values.heightConst,
unitPrice: acc.unitPrice + values.unitPrice,
expense: acc.expense + values.expense,
quantity: acc.quantity + values.quantity,
cost: acc.cost + values.cost,
costExecution: acc.costExecution + values.costExecution,
marginCost: acc.marginCost + values.marginCost,
marginCostExecution: acc.marginCostExecution + values.marginCostExecution,
expenseExecution: acc.expenseExecution + values.expenseExecution,
};
},
{
weight: 0,
area: 0,
steelScreen: 0,
caulking: 0,
rail: 0,
bottom: 0,
boxReinforce: 0,
shaft: 0,
painting: 0,
motor: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
unitPrice: 0,
expense: 0,
quantity: 0,
cost: 0,
costExecution: 0,
marginCost: 0,
marginCostExecution: 0,
expenseExecution: 0,
}
);
}

View File

@@ -1,3 +1,5 @@
export { default as EstimateListClient } from './EstimateListClient';
export { default as EstimateDetailForm } from './EstimateDetailForm';
export * from './types';
export * from './actions';
export * from './actions';
export * from './modals';

View File

@@ -0,0 +1,2 @@
// 공통 컴포넌트 re-export
export { ElectronicApprovalModal } from '../../common/modals/ElectronicApprovalModal';

View File

@@ -0,0 +1,410 @@
'use client';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Printer, Pencil, Send, X as XIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
VisuallyHidden,
DialogTitle,
} from '@/components/ui/dialog';
import { printArea } from '@/lib/print-utils';
import type { EstimateDetailFormData } from '../types';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 금액을 한글로 변환
function amountToKorean(amount: number): string {
const units = ['', '만', '억', '조'];
const smallUnits = ['', '십', '백', '천'];
const digits = ['', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'];
if (amount === 0) return '영';
let result = '';
let unitIndex = 0;
while (amount > 0) {
const segment = amount % 10000;
if (segment > 0) {
let segmentStr = '';
let segmentNum = segment;
for (let i = 0; i < 4 && segmentNum > 0; i++) {
const digit = segmentNum % 10;
if (digit > 0) {
segmentStr = digits[digit] + smallUnits[i] + segmentStr;
}
segmentNum = Math.floor(segmentNum / 10);
}
result = segmentStr + units[unitIndex] + result;
}
amount = Math.floor(amount / 10000);
unitIndex++;
}
return '(금)' + result;
}
interface EstimateDocumentModalProps {
isOpen: boolean;
onClose: () => void;
formData: EstimateDetailFormData;
estimateId?: string;
}
export function EstimateDocumentModal({
isOpen,
onClose,
formData,
estimateId,
}: EstimateDocumentModalProps) {
const router = useRouter();
// 인쇄
const handlePrint = useCallback(() => {
printArea({ title: '견적서 인쇄' });
}, []);
// 수정 페이지로 이동
const handleEdit = useCallback(() => {
if (estimateId) {
onClose();
router.push(`/ko/juil/project/bidding/estimates/${estimateId}/edit`);
}
}, [estimateId, onClose, router]);
// 견적서 문서 데이터
const documentData = {
documentNo: formData.estimateCode || 'ABC123',
createdDate: formData.siteBriefing.briefingDate || '2025년 11월 11일',
recipient: formData.siteBriefing.partnerName || '',
companyName: formData.siteBriefing.companyName || '(주) 주일기업',
projectName: formData.bidInfo.projectName || '',
address: '주소',
amount: formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0),
date: formData.bidInfo.bidDate || '2025년 12월 12일',
contact: {
hp: '010-3679-2188',
tel: '(02) 849-5130',
fax: '(02) 6911-6315',
},
note: '하기와 같이 보내합니다.',
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit} disabled={!estimateId}>
<Pencil className="h-4 w-4 mr-1" />
</Button>
<Button variant="default" size="sm" className="bg-blue-600 hover:bg-blue-700">
<Send className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex justify-between items-start mb-4">
{/* 제목 영역 */}
<div className="flex-1">
<h1 className="text-3xl font-bold text-center tracking-[0.3em]"> </h1>
{/* 문서번호 및 작성일자 */}
<div className="text-sm mt-4 text-center">
<span className="mr-4">: {documentData.documentNo}</span>
<span className="mx-2">|</span>
<span className="ml-4">: {documentData.createdDate}</span>
</div>
</div>
{/* 결재란 (상단 우측) - 3열 3행 */}
<table className="text-xs border-collapse border border-gray-400 ml-4">
<tbody>
<tr>
<td className="border border-gray-400 w-10"></td>
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-3 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-4 py-3 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-4 py-3 text-center whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400"></td>
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* 기본 정보 테이블 */}
<table className="w-full border-collapse mb-6 text-sm">
<tbody>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 w-20 text-center"></td>
<td className="border border-gray-400 px-3 py-2 w-1/4"></td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 w-20 text-center"></td>
<td className="border border-gray-400 px-3 py-2 w-1/4">{documentData.companyName}</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">{documentData.projectName || '현장명'}</td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">{documentData.address || '주소명'}</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">
{amountToKorean(documentData.amount)}
</td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">{documentData.date}</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">
<div className="space-y-0.5 text-xs">
<div>H . P : {documentData.contact.hp}</div>
<div>T E L : {documentData.contact.tel}</div>
<div>F A X : {documentData.contact.fax}</div>
</div>
</td>
</tr>
</tbody>
</table>
{/* 안내 문구 */}
<p className="text-sm mb-6"> .</p>
{/* 견적 요약 테이블 */}
<div className="mb-6">
<div className="mb-2">
<span className="text-sm font-medium"> </span>
</div>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-3 py-2"> </th>
<th className="border border-gray-400 px-3 py-2 w-16"></th>
<th className="border border-gray-400 px-3 py-2 w-16"></th>
<th className="border border-gray-400 px-3 py-2 w-24"> </th>
<th className="border border-gray-400 px-3 py-2 w-24"> </th>
<th className="border border-gray-400 px-3 py-2 w-24"> </th>
<th className="border border-gray-400 px-3 py-2 w-20"> </th>
</tr>
</thead>
<tbody>
{formData.summaryItems.length === 0 ? (
<tr>
<td colSpan={7} className="border border-gray-400 px-3 py-4 text-center text-gray-500">
.
</td>
</tr>
) : (
formData.summaryItems.map((item) => (
<tr key={item.id}>
<td className="border border-gray-400 px-3 py-2">{item.name}</td>
<td className="border border-gray-400 px-3 py-2 text-center">
{item.quantity}
</td>
<td className="border border-gray-400 px-3 py-2 text-center">
{item.unit}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(item.materialCost)}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(item.laborCost)}
</td>
<td className="border border-gray-400 px-3 py-2 text-right font-medium">
{formatAmount(item.totalCost)}
</td>
<td className="border border-gray-400 px-3 py-2">{item.remarks}</td>
</tr>
))
)}
{/* 합계 행 */}
<tr className="font-medium">
<td className="border border-gray-400 px-3 py-2 text-center" colSpan={3}>
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(
formData.summaryItems.reduce((sum, item) => sum + item.materialCost, 0)
)}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(
formData.summaryItems.reduce((sum, item) => sum + item.laborCost, 0)
)}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(
formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0)
)}
</td>
<td className="border border-gray-400 px-3 py-2"></td>
</tr>
{/* 특기사항 행 */}
<tr>
<td colSpan={7} className="border border-gray-400 px-3 py-2 text-sm">
* 특기사항 : 부가세 /
</td>
</tr>
</tbody>
</table>
</div>
{/* 견적 상세 테이블 */}
<div className="mb-6">
<div className="mb-2">
<span className="text-sm font-medium"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-2 py-1 w-8" rowSpan={2}>NO</th>
<th className="border border-gray-400 px-2 py-1" rowSpan={2}></th>
<th className="border border-gray-400 px-2 py-1" rowSpan={2}></th>
<th className="border border-gray-400 px-2 py-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 px-2 py-1" rowSpan={2}></th>
<th className="border border-gray-400 px-2 py-1" rowSpan={2}></th>
<th className="border border-gray-400 px-2 py-1" colSpan={2}> </th>
<th className="border border-gray-400 px-2 py-1" colSpan={2}> </th>
<th className="border border-gray-400 px-2 py-1" colSpan={2}> </th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-2 py-1">(W)</th>
<th className="border border-gray-400 px-2 py-1">(H)</th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
</tr>
</thead>
<tbody>
{formData.detailItems.length === 0 ? (
<tr>
<td colSpan={13} className="border border-gray-400 px-3 py-4 text-center text-gray-500">
.
</td>
</tr>
) : (
formData.detailItems.map((item, index) => (
<tr key={item.id}>
<td className="border border-gray-400 px-2 py-1 text-center">
{index + 1}
</td>
<td className="border border-gray-400 px-2 py-1">{item.name}</td>
<td className="border border-gray-400 px-2 py-1">{item.material}</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.width)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.height)}
</td>
<td className="border border-gray-400 px-2 py-1 text-center">
{item.quantity}
</td>
<td className="border border-gray-400 px-2 py-1 text-center">SET</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.unitPrice)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.materialCost)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.laborCost)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.laborCost * item.quantity)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.totalPrice)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.totalCost)}
</td>
</tr>
))
)}
{/* 합계 행 */}
<tr className="font-medium">
<td className="border border-gray-400 px-2 py-1 text-center" colSpan={5}>
</td>
<td className="border border-gray-400 px-2 py-1 text-center">
{formData.detailItems.reduce((sum, item) => sum + item.quantity, 0)}
</td>
<td className="border border-gray-400 px-2 py-1 text-center">SET</td>
<td className="border border-gray-400 px-2 py-1"></td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(
formData.detailItems.reduce((sum, item) => sum + item.materialCost, 0)
)}
</td>
<td className="border border-gray-400 px-2 py-1"></td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(
formData.detailItems.reduce((sum, item) => sum + item.laborCost * item.quantity, 0)
)}
</td>
<td className="border border-gray-400 px-2 py-1"></td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(
formData.detailItems.reduce((sum, item) => sum + item.totalCost, 0)
)}
</td>
</tr>
{/* 비고 행 */}
<tr>
<td colSpan={13} className="border border-gray-400 px-2 py-1 text-sm">
* :
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,2 @@
export { ElectronicApprovalModal } from './ElectronicApprovalModal';
export { EstimateDocumentModal } from './EstimateDocumentModal';

View File

@@ -0,0 +1,601 @@
'use client';
import React from 'react';
import { X, HelpCircle } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { EstimateDetailItem } from '../types';
import { formatAmount, MOCK_MATERIALS } from '../utils';
import { calculateItemValuesWithApplied, calculateTotalsWithApplied } from '../hooks/useEstimateCalculations';
// 계산식 정보
const FORMULA_INFO: Record<string, string> = {
weight: '면적 × 25',
area: '(가로 × 0.16) × (세로 × 0.5)',
steelScreen: '면적 × 47,500',
caulking: '(세로 × 4) × 조정단가',
rail: '(세로 × 0.2) × 조정단가',
bottom: '가로 × 조정단가',
boxReinforce: '가로 × 조정단가',
shaft: '가로 × 조정단가',
unitPrice: '철제스크린 + 코킹 + 레일 + 하장 + 박스보강 + 샤프트 + 도장 + 모터 + 제어기 + 가로시공비 + 세로시공비',
expense: '단가 × 공과율',
cost: '단가 + 공과',
costExecution: '원가 × 수량',
marginCost: '원가 × 마진율(1.03)',
marginCostExecution: '마진원가 × 수량',
expenseExecution: '공과 × 수량',
};
// 계산식 툴팁이 있는 헤더 컴포넌트
function FormulaHeader({ label, formulaKey, className }: { label: string; formulaKey: string; className?: string }) {
const formula = FORMULA_INFO[formulaKey];
if (!formula) {
return <span>{label}</span>;
}
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className={`inline-flex items-center gap-1 cursor-help ${className || ''}`}>
{label}
<HelpCircle className="h-3.5 w-3.5 text-gray-400 hover:text-gray-600" />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs font-medium text-gray-700">{label} </p>
<p className="text-xs text-gray-500 mt-1">{formula}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// appliedPrices 타입 정의
export interface AppliedPrices {
caulking: number;
rail: number;
bottom: number;
boxReinforce: number;
shaft: number;
painting: number;
motor: number;
controller: number;
}
interface EstimateDetailTableSectionProps {
detailItems: EstimateDetailItem[];
appliedPrices: AppliedPrices | null;
isViewMode: boolean;
onAddItems: (count: number) => void;
onRemoveItem: (id: string) => void;
onRemoveSelected: () => void;
onItemChange: (id: string, field: keyof EstimateDetailItem, value: string | number) => void;
onSelectItem: (id: string, selected: boolean) => void;
onSelectAll: (selected: boolean) => void;
onApplyAdjustedPrice: () => void;
onReset: () => void;
}
export function EstimateDetailTableSection({
detailItems,
appliedPrices,
isViewMode,
onAddItems,
onRemoveItem,
onRemoveSelected,
onItemChange,
onSelectItem,
onSelectAll,
onApplyAdjustedPrice,
onReset,
}: EstimateDetailTableSectionProps) {
const selectedCount = detailItems.filter((item) => (item as unknown as { selected?: boolean }).selected).length;
const allSelected = detailItems.length > 0 && detailItems.every((item) => (item as unknown as { selected?: boolean }).selected);
const totals = calculateTotalsWithApplied(detailItems, appliedPrices);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div className="flex items-center gap-4">
<CardTitle className="text-lg whitespace-nowrap"> </CardTitle>
{!isViewMode && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{selectedCount} </span>
<Button
type="button"
variant="default"
size="sm"
className="bg-gray-900 hover:bg-gray-800"
onClick={onRemoveSelected}
>
</Button>
<Button
type="button"
variant="default"
size="sm"
className="bg-orange-500 hover:bg-orange-600"
onClick={onApplyAdjustedPrice}
>
</Button>
</div>
)}
</div>
{!isViewMode && (
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
defaultValue={1}
className="w-16 text-center"
id="detail-add-count"
/>
<Button
type="button"
variant="default"
size="sm"
className="bg-gray-900 hover:bg-gray-800"
onClick={() => {
const countInput = document.getElementById('detail-add-count') as HTMLInputElement;
const count = Math.max(1, parseInt(countInput?.value || '1', 10));
onAddItems(count);
}}
>
</Button>
{/* TODO: 견적 상세 기획서 수정 후 초기화 버튼 및 테이블 항목/데이터 재작업 필요
<Button type="button" variant="outline" size="sm" onClick={onReset}>
초기화
</Button>
*/}
</div>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto max-h-[600px]">
<Table>
<TableHeader className="sticky top-0 bg-white z-10">
<TableRow className="bg-gray-100">
{!isViewMode && (
<TableHead className="w-[40px] text-center sticky left-0 bg-gray-100 z-20">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300"
checked={allSelected}
onChange={(e) => onSelectAll(e.target.checked)}
/>
</TableHead>
)}
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[70px] text-right">
<FormulaHeader label="무게" formulaKey="weight" />
</TableHead>
<TableHead className="w-[70px] text-right">
<FormulaHeader label="면적" formulaKey="area" />
</TableHead>
<TableHead className="w-[90px] text-right">
<FormulaHeader label="철제,스크린" formulaKey="steelScreen" />
</TableHead>
<TableHead className="w-[80px] text-right">
<FormulaHeader label="코킹" formulaKey="caulking" />
</TableHead>
<TableHead className="w-[80px] text-right">
<FormulaHeader label="레일" formulaKey="rail" />
</TableHead>
<TableHead className="w-[80px] text-right">
<FormulaHeader label="하장" formulaKey="bottom" />
</TableHead>
<TableHead className="w-[90px] text-right">
<FormulaHeader label="박스+보강" formulaKey="boxReinforce" />
</TableHead>
<TableHead className="w-[80px] text-right">
<FormulaHeader label="샤프트" formulaKey="shaft" />
</TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[90px] text-right">
<FormulaHeader label="단가" formulaKey="unitPrice" />
</TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[80px] text-right">
<FormulaHeader label="공과" formulaKey="expense" />
</TableHead>
<TableHead className="w-[50px] text-right"></TableHead>
<TableHead className="w-[90px] text-right">
<FormulaHeader label="원가" formulaKey="cost" />
</TableHead>
<TableHead className="w-[90px] text-right">
<FormulaHeader label="원가실행" formulaKey="costExecution" />
</TableHead>
<TableHead className="w-[90px] text-right">
<FormulaHeader label="마진원가" formulaKey="marginCost" />
</TableHead>
<TableHead className="w-[100px] text-right">
<FormulaHeader label="마진원가실행" formulaKey="marginCostExecution" />
</TableHead>
<TableHead className="w-[90px] text-right">
<FormulaHeader label="공과실행" formulaKey="expenseExecution" />
</TableHead>
{!isViewMode && <TableHead className="w-[50px] text-center"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{detailItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={isViewMode ? 27 : 29}
className="text-center text-gray-500 py-8"
>
.
</TableCell>
</TableRow>
) : (
<>
{detailItems.map((item) => {
const values = calculateItemValuesWithApplied(item, appliedPrices);
return (
<TableRow key={item.id}>
{!isViewMode && (
<TableCell className="text-center sticky left-0 bg-white">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300"
checked={(item as unknown as { selected?: boolean }).selected || false}
onChange={(e) => onSelectItem(item.id, e.target.checked)}
/>
</TableCell>
)}
{/* 01: 명칭 */}
<TableCell>
<Input
value={item.name}
onChange={(e) => onItemChange(item.id, 'name', e.target.value)}
disabled={isViewMode}
className={`w-full min-w-[80px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 02: 제품 */}
<TableCell>
<Select
value={item.material}
onValueChange={(val) => onItemChange(item.id, 'material', val)}
disabled={isViewMode}
>
<SelectTrigger className={`w-full min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{MOCK_MATERIALS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
{/* 03: 가로 */}
<TableCell>
<Input
type="number"
step="0.01"
value={item.width}
onChange={(e) => onItemChange(item.id, 'width', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 04: 세로 */}
<TableCell>
<Input
type="number"
step="0.01"
value={item.height}
onChange={(e) => onItemChange(item.id, 'height', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 05: 무게 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
step="0.01"
value={values.weight.toFixed(2)}
onChange={(e) => onItemChange(item.id, 'calcWeight', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 06: 면적 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
step="0.01"
value={values.area.toFixed(2)}
onChange={(e) => onItemChange(item.id, 'calcArea', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 07: 철제,스크린 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
value={values.steelScreen}
onChange={(e) => onItemChange(item.id, 'calcSteelScreen', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[80px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 08: 코킹 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
value={values.caulking}
onChange={(e) => onItemChange(item.id, 'calcCaulking', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 09: 레일 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
value={values.rail}
onChange={(e) => onItemChange(item.id, 'calcRail', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 10: 하장 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
value={values.bottom}
onChange={(e) => onItemChange(item.id, 'calcBottom', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 11: 박스+보강 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
value={values.boxReinforce}
onChange={(e) => onItemChange(item.id, 'calcBoxReinforce', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[80px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 12: 샤프트 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
value={values.shaft}
onChange={(e) => onItemChange(item.id, 'calcShaft', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 13: 도장 */}
<TableCell>
<Select
value={String(item.coating || '')}
onValueChange={(val) => onItemChange(item.id, 'coating', Number(val))}
disabled={isViewMode}
>
<SelectTrigger className={`w-full min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="50000">A</SelectItem>
<SelectItem value="80000">B</SelectItem>
</SelectContent>
</Select>
</TableCell>
{/* 14: 모터 */}
<TableCell>
<Select
value={String(item.mounting || '300000')}
onValueChange={(val) => onItemChange(item.id, 'mounting', Number(val))}
disabled={isViewMode}
>
<SelectTrigger className={`w-full min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="300000"> 300,000</SelectItem>
<SelectItem value="500000"> 500,000</SelectItem>
</SelectContent>
</Select>
</TableCell>
{/* 15: 제어기 */}
<TableCell>
<Select
value={String(item.controller || '')}
onValueChange={(val) => onItemChange(item.id, 'controller', Number(val))}
disabled={isViewMode}
>
<SelectTrigger className={`w-full min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="150000"> 150,000</SelectItem>
<SelectItem value="250000"> 250,000</SelectItem>
</SelectContent>
</Select>
</TableCell>
{/* 16: 가로시공비 */}
<TableCell>
<Select
value={String(item.widthConstruction || '')}
onValueChange={(val) => onItemChange(item.id, 'widthConstruction', Number(val))}
disabled={isViewMode}
>
<SelectTrigger className={`w-full min-w-[90px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="300000">3.01~4.0M</SelectItem>
<SelectItem value="400000">4.01~5.0M</SelectItem>
<SelectItem value="500000">5.01~6.0M</SelectItem>
<SelectItem value="600000">6.01~7.0M</SelectItem>
</SelectContent>
</Select>
</TableCell>
{/* 17: 세로시공비 */}
<TableCell>
<Select
value={String(item.heightConstruction || '')}
onValueChange={(val) => onItemChange(item.id, 'heightConstruction', Number(val))}
disabled={isViewMode}
>
<SelectTrigger className={`w-full min-w-[90px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5000">3.51~4.5M</SelectItem>
<SelectItem value="8000">4.51~5.5M</SelectItem>
<SelectItem value="10000">5.51~6.5M</SelectItem>
</SelectContent>
</Select>
</TableCell>
{/* 18: 단가 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
value={values.unitPrice}
onChange={(e) => onItemChange(item.id, 'calcUnitPrice', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[80px] font-medium ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 19: 공과율 */}
<TableCell>
<Input
type="number"
step="0.01"
value={item.expense}
onChange={(e) => onItemChange(item.id, 'expense', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 20: 공과 (인풋, 계산값 표시 + 수정 가능) */}
<TableCell>
<Input
type="number"
value={values.expense}
onChange={(e) => onItemChange(item.id, 'calcExpense', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 21: 수량 */}
<TableCell>
<Input
type="number"
min={1}
value={item.quantity}
onChange={(e) => onItemChange(item.id, 'quantity', Number(e.target.value))}
disabled={isViewMode}
className={`text-right min-w-[40px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
{/* 22: 원가 */}
<TableCell className="text-right bg-gray-50">{formatAmount(values.cost)}</TableCell>
{/* 23: 원가실행 */}
<TableCell className="text-right bg-gray-50">{formatAmount(values.costExecution)}</TableCell>
{/* 24: 마진원가 */}
<TableCell className="text-right bg-gray-50">{formatAmount(values.marginCost)}</TableCell>
{/* 25: 마진원가실행 */}
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(values.marginCostExecution)}</TableCell>
{/* 26: 공과실행 */}
<TableCell className="text-right bg-gray-50">{formatAmount(values.expenseExecution)}</TableCell>
{!isViewMode && (
<TableCell className="text-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600"
onClick={() => onRemoveItem(item.id)}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
);
})}
{/* 합계 행 */}
{detailItems.length > 0 && (
<TableRow className="bg-orange-50 font-medium border-t-2 border-orange-300">
{!isViewMode && <TableCell className="sticky left-0 bg-orange-50"></TableCell>}
<TableCell colSpan={4} className="text-center font-bold"></TableCell>
<TableCell className="text-right">{totals.weight.toFixed(2)}</TableCell>
<TableCell className="text-right">{totals.area.toFixed(2)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.steelScreen)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.caulking)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.rail)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.bottom)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.boxReinforce)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.shaft)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.painting)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.motor)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.controller)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.widthConstruction)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.heightConstruction)}</TableCell>
<TableCell className="text-right font-bold">{formatAmount(totals.unitPrice)}</TableCell>
<TableCell className="text-right">-</TableCell>
<TableCell className="text-right">{formatAmount(totals.expense)}</TableCell>
<TableCell className="text-right">{totals.quantity}</TableCell>
<TableCell className="text-right">{formatAmount(totals.cost)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.costExecution)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.marginCost)}</TableCell>
<TableCell className="text-right font-bold">{formatAmount(totals.marginCostExecution)}</TableCell>
<TableCell className="text-right">{formatAmount(totals.expenseExecution)}</TableCell>
{!isViewMode && <TableCell></TableCell>}
</TableRow>
)}
</>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,277 @@
'use client';
import React from 'react';
import { FileText, X, Upload, Download } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { EstimateDetailFormData, BidDocument } from '../types';
import { STATUS_STYLES, STATUS_LABELS, VAT_TYPE_OPTIONS } from '../types';
import { formatAmount } from '../utils';
interface EstimateInfoSectionProps {
formData: EstimateDetailFormData;
isViewMode: boolean;
isDragging: boolean;
documentInputRef: React.RefObject<HTMLInputElement | null>;
onFormDataChange: (updates: Partial<EstimateDetailFormData>) => void;
onBidInfoChange: (field: string, value: string | number) => void;
onDocumentUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
onDocumentRemove: (docId: string) => void;
onDragOver: (e: React.DragEvent<HTMLDivElement>) => void;
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => void;
onDrop: (e: React.DragEvent<HTMLDivElement>) => void;
}
export function EstimateInfoSection({
formData,
isViewMode,
isDragging,
documentInputRef,
onFormDataChange,
onBidInfoChange,
onDocumentUpload,
onDocumentRemove,
onDragOver,
onDragLeave,
onDrop,
}: EstimateInfoSectionProps) {
return (
<>
{/* 견적 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
value={formatAmount(formData.estimateAmount)}
disabled
className="bg-gray-50 text-right"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
<span className={STATUS_STYLES[formData.status]}>
{STATUS_LABELS[formData.status]}
</span>
</div>
</div>
</CardContent>
</Card>
{/* 현장설명회 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.siteBriefing.briefingCode} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.siteBriefing.partnerName} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Input value={formData.siteBriefing.briefingDate} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input value={formData.siteBriefing.attendee} disabled className="bg-gray-50" />
</div>
</CardContent>
</Card>
{/* 입찰 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
value={formData.bidInfo.projectName}
onChange={(e) => onBidInfoChange('projectName', e.target.value)}
disabled={isViewMode}
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
type="date"
value={formData.bidInfo.bidDate}
onChange={(e) => onBidInfoChange('bidDate', e.target.value)}
disabled={isViewMode}
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Input
type="number"
value={formData.bidInfo.siteCount}
onChange={(e) => onBidInfoChange('siteCount', Number(e.target.value))}
disabled={isViewMode}
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="flex items-center gap-2">
<Input
type="date"
value={formData.bidInfo.constructionStartDate}
onChange={(e) => onBidInfoChange('constructionStartDate', e.target.value)}
disabled={isViewMode}
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
value={formData.bidInfo.constructionEndDate}
onChange={(e) => onBidInfoChange('constructionEndDate', e.target.value)}
disabled={isViewMode}
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Select
value={formData.bidInfo.vatType}
onValueChange={(val) => onBidInfoChange('vatType', val)}
disabled={isViewMode}
>
<SelectTrigger className={isViewMode ? 'bg-gray-50' : 'bg-white'}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{VAT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 업무 보고 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"> </Label>
<Textarea
value={formData.bidInfo.workReport}
onChange={(e) => onBidInfoChange('workReport', e.target.value)}
placeholder="업무 보고 내용"
disabled={isViewMode}
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
rows={3}
/>
</div>
{/* 현장설명회 자료 */}
<div className="space-y-4">
<Label className="text-sm font-medium text-gray-700"> </Label>
<input
ref={documentInputRef}
type="file"
onChange={onDocumentUpload}
className="hidden"
/>
{!isViewMode && (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'hover:border-primary/50 cursor-pointer'
}`}
onClick={() => documentInputRef.current?.click()}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<Upload
className={`w-12 h-12 mx-auto mb-2 ${isDragging ? 'text-primary' : 'text-gray-400'}`}
/>
<p className="text-sm text-gray-600">
{isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'}
</p>
</div>
)}
{formData.bidInfo.documents.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.bidInfo.documents.map((doc) => (
<div
key={doc.id}
className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg border"
>
<FileText className="w-4 h-4 text-primary" />
<span className="text-sm">{doc.fileName}</span>
{isViewMode ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
const link = document.createElement('a');
link.href = doc.fileUrl;
link.download = doc.fileName;
link.click();
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
}}
>
<Download className="h-3 w-3 mr-1" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => onDocumentRemove(doc.id)}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
</>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import React from 'react';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { EstimateSummaryItem } from '../types';
import { formatAmount } from '../utils';
interface EstimateSummarySectionProps {
summaryItems: EstimateSummaryItem[];
summaryMemo: string;
isViewMode: boolean;
onAddItem: () => void;
onRemoveItem: (id: string) => void;
onItemChange: (id: string, field: keyof EstimateSummaryItem, value: string | number) => void;
onMemoChange: (memo: string) => void;
}
export function EstimateSummarySection({
summaryItems,
summaryMemo,
isViewMode,
onAddItem,
onRemoveItem,
onItemChange,
onMemoChange,
}: EstimateSummarySectionProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg"> </CardTitle>
{!isViewMode && (
<Button type="button" variant="outline" size="sm" onClick={onAddItem}>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[150px]"></TableHead>
{!isViewMode && <TableHead className="w-[60px] text-center"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{summaryItems.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 7 : 8} className="text-center text-gray-500 py-8">
.
</TableCell>
</TableRow>
) : (
summaryItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<Input
value={item.name}
onChange={(e) => onItemChange(item.id, 'name', e.target.value)}
disabled={isViewMode}
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
/>
</TableCell>
<TableCell>
<Input
type="number"
value={item.quantity}
onChange={(e) => onItemChange(item.id, 'quantity', Number(e.target.value))}
disabled={isViewMode}
className={`text-center ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
<TableCell>
<Input
value={item.unit}
onChange={(e) => onItemChange(item.id, 'unit', e.target.value)}
disabled={isViewMode}
className={`text-center ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
<TableCell>
<Input
type="number"
value={item.materialCost}
onChange={(e) => onItemChange(item.id, 'materialCost', Number(e.target.value))}
disabled={isViewMode}
className={`text-right ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
<TableCell>
<Input
type="number"
value={item.laborCost}
onChange={(e) => onItemChange(item.id, 'laborCost', Number(e.target.value))}
disabled={isViewMode}
className={`text-right ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.totalCost)}
</TableCell>
<TableCell>
<Input
value={item.remarks}
onChange={(e) => onItemChange(item.id, 'remarks', e.target.value)}
disabled={isViewMode}
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
/>
</TableCell>
{!isViewMode && (
<TableCell className="text-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600"
onClick={() => onRemoveItem(item.id)}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
{/* 합계 행 */}
{summaryItems.length > 0 && (
<TableRow className="bg-gray-50 font-medium">
<TableCell colSpan={3} className="text-center">
</TableCell>
<TableCell className="text-right">
{formatAmount(summaryItems.reduce((sum, item) => sum + item.materialCost, 0))}
</TableCell>
<TableCell className="text-right">
{formatAmount(summaryItems.reduce((sum, item) => sum + item.laborCost, 0))}
</TableCell>
<TableCell className="text-right">
{formatAmount(summaryItems.reduce((sum, item) => sum + item.totalCost, 0))}
</TableCell>
<TableCell colSpan={isViewMode ? 1 : 2}></TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 메모 입력 */}
<div className="mt-4 space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Textarea
value={summaryMemo}
onChange={(e) => onMemoChange(e.target.value)}
placeholder="견적 관련 메모를 입력하세요"
disabled={isViewMode}
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
rows={3}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { ExpenseItem } from '../types';
import { formatAmount, MOCK_EXPENSES } from '../utils';
interface ExpenseDetailSectionProps {
expenseItems: ExpenseItem[];
isViewMode: boolean;
onAddItems: (count: number) => void;
onRemoveSelected: () => void;
onItemChange: (id: string, field: keyof ExpenseItem, value: string | number) => void;
onSelectItem: (id: string, selected: boolean) => void;
onSelectAll: (selected: boolean) => void;
}
export function ExpenseDetailSection({
expenseItems,
isViewMode,
onAddItems,
onRemoveSelected,
onItemChange,
onSelectItem,
onSelectAll,
}: ExpenseDetailSectionProps) {
const selectedCount = expenseItems.filter((item) => item.selected).length;
const allSelected = expenseItems.length > 0 && expenseItems.every((item) => item.selected);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div className="flex items-center gap-4">
<CardTitle className="text-lg"> </CardTitle>
{!isViewMode && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{selectedCount} </span>
<Button
type="button"
variant="default"
size="sm"
className="bg-gray-900 hover:bg-gray-800"
onClick={onRemoveSelected}
disabled={selectedCount === 0}
>
</Button>
</div>
)}
</div>
{!isViewMode && (
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
defaultValue={1}
className="w-16 text-center"
id="expense-add-count"
/>
<Button
type="button"
variant="default"
size="sm"
className="bg-gray-900 hover:bg-gray-800"
onClick={() => {
const countInput = document.getElementById('expense-add-count') as HTMLInputElement;
const count = Math.max(1, parseInt(countInput?.value || '1', 10));
onAddItems(count);
}}
>
</Button>
</div>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-100">
{!isViewMode && (
<TableHead className="w-[50px] text-center">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300"
checked={allSelected}
onChange={(e) => onSelectAll(e.target.checked)}
/>
</TableHead>
)}
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{expenseItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={isViewMode ? 2 : 3}
className="text-center text-gray-500 py-8"
>
.
</TableCell>
</TableRow>
) : (
expenseItems.map((item) => (
<TableRow key={item.id}>
{!isViewMode && (
<TableCell className="text-center">
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300"
checked={item.selected || false}
onChange={(e) => onSelectItem(item.id, e.target.checked)}
/>
</TableCell>
)}
<TableCell>
<Select
value={item.name}
onValueChange={(val) => onItemChange(item.id, 'name', val)}
disabled={isViewMode}
>
<SelectTrigger className={isViewMode ? 'bg-gray-50' : 'bg-white'}>
<SelectValue placeholder="공과 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_EXPENSES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Input
type="number"
value={item.amount}
onChange={(e) => onItemChange(item.id, 'amount', Number(e.target.value))}
disabled={isViewMode}
className={`text-right ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
</TableRow>
))
)}
{/* 합계 행 */}
{expenseItems.length > 0 && (
<TableRow className="bg-gray-50 font-medium">
<TableCell colSpan={isViewMode ? 1 : 2} className="text-center">
</TableCell>
<TableCell className="text-right">
{formatAmount(expenseItems.reduce((sum, item) => sum + item.amount, 0))}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { PriceAdjustmentData } from '../types';
import { formatAmount } from '../utils';
interface PriceAdjustmentSectionProps {
priceAdjustmentData: PriceAdjustmentData;
isViewMode: boolean;
onPriceChange: (key: keyof PriceAdjustmentData, value: number) => void;
onSave: () => void;
onApplyAll: () => void;
onReset: () => void;
}
const PRICE_KEYS: (keyof PriceAdjustmentData)[] = [
'caulking',
'rail',
'bottom',
'boxReinforce',
'shaft',
'painting',
'motor',
'controller',
];
const PRICE_LABELS: Record<keyof PriceAdjustmentData, string> = {
caulking: '코킹',
rail: '레일',
bottom: '하장',
boxReinforce: '박스+보강',
shaft: '샤프트',
painting: '도장',
motor: '모터',
controller: '제어기',
};
export function PriceAdjustmentSection({
priceAdjustmentData,
isViewMode,
onPriceChange,
onSave,
onApplyAll,
onReset,
}: PriceAdjustmentSectionProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-4">
<CardTitle className="text-lg"> </CardTitle>
{!isViewMode && (
<div className="flex gap-2">
<Button
type="button"
variant="default"
size="sm"
className="bg-gray-900 hover:bg-gray-800"
onClick={onSave}
>
</Button>
<Button
type="button"
variant="default"
size="sm"
className="bg-orange-500 hover:bg-orange-600"
onClick={onApplyAll}
>
</Button>
<Button type="button" variant="outline" size="sm" onClick={onReset}>
</Button>
</div>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table className="min-w-[1000px]">
<TableHeader>
<TableRow className="bg-gray-100">
<TableHead className="w-[100px] text-center"></TableHead>
{PRICE_KEYS.map((key) => (
<TableHead key={key} className="w-[100px] text-right">
{PRICE_LABELS[key]}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* 매입단가 행 */}
<TableRow>
<TableCell className="font-medium text-center bg-gray-50"></TableCell>
{PRICE_KEYS.map((key) => (
<TableCell key={key} className="text-right">
{formatAmount(priceAdjustmentData[key].purchasePrice)}
</TableCell>
))}
</TableRow>
{/* 마진율 행 */}
<TableRow>
<TableCell className="font-medium text-center bg-gray-50">(%)</TableCell>
{PRICE_KEYS.map((key) => (
<TableCell key={key} className="text-right">
{priceAdjustmentData[key].marginRate.toFixed(1)}%
</TableCell>
))}
</TableRow>
{/* 판매단가 행 */}
<TableRow>
<TableCell className="font-medium text-center bg-gray-50"></TableCell>
{PRICE_KEYS.map((key) => (
<TableCell key={key} className="text-right">
{formatAmount(priceAdjustmentData[key].sellingPrice)}
</TableCell>
))}
</TableRow>
{/* 조정단가 행 (입력 가능) */}
<TableRow className="bg-orange-50">
<TableCell className="font-medium text-center bg-orange-100"></TableCell>
{PRICE_KEYS.map((key) => (
<TableCell key={key}>
<Input
type="number"
value={priceAdjustmentData[key].adjustedPrice}
onChange={(e) => onPriceChange(key, Number(e.target.value))}
disabled={isViewMode}
className={`text-right ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
/>
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,5 @@
export * from './EstimateInfoSection';
export * from './EstimateSummarySection';
export * from './ExpenseDetailSection';
export * from './PriceAdjustmentSection';
export * from './EstimateDetailTableSection';

View File

@@ -3,10 +3,306 @@
*/
// 견적 상태
export type EstimateStatus = 'drafting' | 'completed';
export type EstimateStatus = 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold';
// 낙찰 상태
export type AwardStatus = 'pending' | 'awarded' | 'failed';
// =====================================================
// 견적 상세 관련 타입
// =====================================================
// 견적 요약 항목
export interface EstimateSummaryItem {
id: string;
name: string; // 명칭
quantity: number; // 수량
unit: string; // 단위
materialCost: number; // 재료비
laborCost: number; // 노무비
totalCost: number; // 합계
remarks: string; // 비고
}
// 공과 항목
export interface ExpenseItem {
id: string;
name: string; // 공과명
amount: number; // 금액
selected?: boolean; // 선택 여부
}
// 품목 단가 조정 - 품목별 단가 정보
export interface PriceAdjustmentItemPrice {
purchasePrice: number; // 매입단가
marginRate: number; // 마진율 (%)
sellingPrice: number; // 판매단가
adjustedPrice: number; // 조정단가
}
// 품목 단가 조정 전체 구조
export interface PriceAdjustmentData {
caulking: PriceAdjustmentItemPrice; // 코킹
rail: PriceAdjustmentItemPrice; // 레일
bottom: PriceAdjustmentItemPrice; // 하장
boxReinforce: PriceAdjustmentItemPrice; // 박스+보강
shaft: PriceAdjustmentItemPrice; // 샤프트
painting: PriceAdjustmentItemPrice; // 도장
motor: PriceAdjustmentItemPrice; // 모터
controller: PriceAdjustmentItemPrice; // 제어기
}
// 레거시 호환용 (기존 구조)
export interface PriceAdjustmentItem {
id: string;
category: string; // 카테고리 (배합비, 재단비, 판매단가, 조립단가)
unitPrice: number; // 단가
coating: number; // 코팅
batting: number; // 배팅
boxReinforce: number; // 박스+보강
painting: number; // 도장
total: number; // 합계
}
// 견적 상세 항목 (테이블 row)
export interface EstimateDetailItem {
id: string;
no: number; // 번호
name: string; // 명칭
material: string; // 재료
width: number; // 가로 (M, 소수점 둘째자리)
height: number; // 세로 (M, 소수점 둘째자리)
quantity: number; // 수량
box: number; // 박스
assembly: number; // 조립
coating: number; // 코팅/도장 (셀렉트)
batting: number; // 배팅
mounting: number; // 모터 (셀렉트)
fitting: number; // 착장
controller: number; // 제어기 (셀렉트)
widthConstruction: number; // 가로 시공비 (수동 셀렉트)
heightConstruction: number; // 세로 시공비 (자동 셀렉트)
materialCost: number; // 재료비
laborCost: number; // 노무비
quantityPrice: number; // 수량*단가
expenseQuantity: number; // 공과*수량
expenseTotal: number; // 공과합
totalCost: number; // 합계비
otherCost: number; // 기타원가합
marginCost: number; // 마진원가
totalPrice: number; // 단가합계
unitPrice: number; // 단가
expense: number; // 공과율 (인풋)
marginRate: number; // 마진율
unitQuantity: number; // 단가수량
expenseResult: number; // 공과 실행
marginActual: number; // 마진원가+수량
// === 계산 필드 (인풋, 계산값 표시 + 수정 가능) ===
// 기본값은 자동 계산, 사용자가 수정하면 그 값 유지
calcWeight?: number; // 무게 = 면적*25
calcArea?: number; // 면적 = (가로×0.16)*(세로×0.5)
calcSteelScreen?: number; // 철재,스크린 = 면적*47500
calcCaulking?: number; // 코킹 = (세로*4)*단가
calcRail?: number; // 레일 = (세로×0.2)*단가
calcBottom?: number; // 하장 = 가로*단가
calcBoxReinforce?: number; // 박스+보강 = 가로*단가
calcShaft?: number; // 샤프트 = 가로*단가
calcUnitPrice?: number; // 단가 = (7)~(17)의 합
calcExpense?: number; // 공과 = 단가×공과율
// === 개별 항목 조정단가 (선택 적용 시 사용) ===
// "조정 단가 적용" 버튼으로 선택한 항목에만 적용되는 단가
adjustedCaulking?: number; // 코킹 조정단가
adjustedRail?: number; // 레일 조정단가
adjustedBottom?: number; // 하장 조정단가
adjustedBoxReinforce?: number; // 박스+보강 조정단가
adjustedShaft?: number; // 샤프트 조정단가
adjustedPainting?: number; // 도장 조정단가
adjustedMotor?: number; // 모터 조정단가
adjustedController?: number; // 제어기 조정단가
}
// 결재자 정보 - 공통 타입 re-export
export type { ApprovalPerson, ElectronicApproval } from '../common/types';
export { getEmptyElectronicApproval } from '../common/types';
// 현장설명회 정보 (견적 상세용)
export interface SiteBriefingInfo {
briefingCode: string; // 현설번호
partnerName: string; // 거래처명
companyName: string; // 회사명
briefingDate: string; // 현장설명회 일자
attendee: string; // 참석자
}
// 입찰 정보 (견적 상세용)
export interface BidInfo {
projectName: string; // 현장명
bidDate: string; // 입찰일자
siteCount: number; // 개소
constructionPeriod: string; // 공사기간 (startDate ~ endDate)
constructionStartDate: string;
constructionEndDate: string;
vatType: string; // 부가세
workReport: string; // 업무 보고
documents: BidDocument[]; // 현장설명회 자료
}
// 입찰 문서
export interface BidDocument {
id: string;
fileName: string;
fileUrl: string;
fileSize: number;
}
// 견적 상세 전체 데이터
export interface EstimateDetail extends Estimate {
// 현장설명회 정보
siteBriefing: SiteBriefingInfo;
// 입찰 정보
bidInfo: BidInfo;
// 견적 요약 정보
summaryItems: EstimateSummaryItem[];
// 공과 상세
expenseItems: ExpenseItem[];
// 품목 단가 조정
priceAdjustments: PriceAdjustmentItem[];
// 견적 상세 테이블
detailItems: EstimateDetailItem[];
// 전자결재 정보
approval?: ElectronicApproval;
}
// 견적 상세 폼 데이터 (수정용)
export interface EstimateDetailFormData {
// 견적 정보
estimateCode: string;
estimatorId: string;
estimatorName: string;
estimateAmount: number;
status: EstimateStatus;
// 현장설명회 정보
siteBriefing: SiteBriefingInfo;
// 입찰 정보
bidInfo: BidInfo;
// 견적 요약 정보
summaryItems: EstimateSummaryItem[];
// 견적 요약 메모
summaryMemo: string;
// 공과 상세
expenseItems: ExpenseItem[];
// 품목 단가 조정 (레거시)
priceAdjustments: PriceAdjustmentItem[];
// 품목 단가 조정 (신규 구조)
priceAdjustmentData: PriceAdjustmentData;
// 견적 상세 테이블
detailItems: EstimateDetailItem[];
// 전자결재 정보
approval: ElectronicApproval;
}
// 부가세 옵션
export const VAT_TYPE_OPTIONS = [
{ value: 'included', label: '부가세 포함' },
{ value: 'excluded', label: '부가세 별도' },
];
// 빈 단가 조정 항목 생성
function getEmptyPriceAdjustmentItemPrice(): PriceAdjustmentItemPrice {
return {
purchasePrice: 10000,
marginRate: 3.0,
sellingPrice: 10300,
adjustedPrice: 10300,
};
}
// 빈 단가 조정 데이터 생성
export function getEmptyPriceAdjustmentData(): PriceAdjustmentData {
return {
caulking: getEmptyPriceAdjustmentItemPrice(),
rail: getEmptyPriceAdjustmentItemPrice(),
bottom: getEmptyPriceAdjustmentItemPrice(),
boxReinforce: getEmptyPriceAdjustmentItemPrice(),
shaft: getEmptyPriceAdjustmentItemPrice(),
painting: getEmptyPriceAdjustmentItemPrice(),
motor: getEmptyPriceAdjustmentItemPrice(),
controller: getEmptyPriceAdjustmentItemPrice(),
};
}
// 빈 폼 데이터 생성
export function getEmptyEstimateDetailFormData(): EstimateDetailFormData {
return {
estimateCode: '',
estimatorId: '',
estimatorName: '',
estimateAmount: 0,
status: 'pending',
siteBriefing: {
briefingCode: '',
partnerName: '',
companyName: '',
briefingDate: '',
attendee: '',
},
bidInfo: {
projectName: '',
bidDate: '',
siteCount: 0,
constructionPeriod: '',
constructionStartDate: '',
constructionEndDate: '',
vatType: 'excluded',
workReport: '',
documents: [],
},
summaryItems: [],
summaryMemo: '',
expenseItems: [],
priceAdjustments: [],
priceAdjustmentData: getEmptyPriceAdjustmentData(),
detailItems: [],
approval: {
approvers: [],
references: [],
},
};
}
// EstimateDetail을 FormData로 변환
export function estimateDetailToFormData(detail: EstimateDetail): EstimateDetailFormData {
return {
estimateCode: detail.estimateCode,
estimatorId: detail.estimatorId,
estimatorName: detail.estimatorName,
estimateAmount: detail.estimateAmount,
status: detail.status,
siteBriefing: detail.siteBriefing,
bidInfo: detail.bidInfo,
summaryItems: detail.summaryItems,
summaryMemo: '',
expenseItems: detail.expenseItems,
priceAdjustments: detail.priceAdjustments,
priceAdjustmentData: getEmptyPriceAdjustmentData(),
detailItems: detail.detailItems,
approval: detail.approval || { approvers: [], references: [] },
};
}
// 견적 타입
export interface Estimate {
@@ -21,14 +317,13 @@ export interface Estimate {
estimatorName: string; // 견적자명
// 견적 정보
itemCount: number; // (품목 수)
itemCount: number; // 총 개소 (품목 수)
estimateAmount: number; // 견적금액
distributionDate: string | null; // 견적배부
completedDate: string | null; // 견적완료
bidDate: string | null; // 입찰일
// 상태 정보
status: EstimateStatus; // 견적 상태
awardStatus: AwardStatus; // 낙찰 상태
// 메타 정보
createdAt: string;
@@ -38,10 +333,9 @@ export interface Estimate {
// 견적 통계
export interface EstimateStats {
total: number; // 전체
drafting: number; // 견적작성중
total: number; // 전체 견적
pending: number; // 견적대기
completed: number; // 견적완료
awarded: number; // 낙찰
}
// 견적 필터
@@ -69,39 +363,37 @@ export interface EstimateListResponse {
// 상태 옵션
export const ESTIMATE_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'drafting', label: '견적작성중' },
{ value: 'pending', label: '견적대기' },
{ value: 'approval_waiting', label: '승인대기' },
{ value: 'completed', label: '견적완료' },
{ value: 'rejected', label: '반려' },
{ value: 'hold', label: '보류' },
];
// 정렬 옵션
export const ESTIMATE_SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '견적금액 높은순' },
{ value: 'amountAsc', label: '견적금액 낮은순' },
{ value: 'bidDateDesc', label: '입찰일 최신순' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'projectNameAsc', label: '현장명 오름차순' },
{ value: 'projectNameDesc', label: '현장명 내림차순' },
];
// 상태별 스타일
export const STATUS_STYLES: Record<EstimateStatus, string> = {
drafting: 'text-red-500 font-medium',
pending: 'text-orange-500 font-medium',
approval_waiting: 'text-blue-500 font-medium',
completed: 'text-gray-600',
rejected: 'text-red-500 font-medium',
hold: 'text-gray-400',
};
export const STATUS_LABELS: Record<EstimateStatus, string> = {
drafting: '견적작성중',
pending: '견적대기',
approval_waiting: '승인대기',
completed: '견적완료',
};
// 낙찰 상태 라벨
export const AWARD_STATUS_LABELS: Record<AwardStatus, string> = {
pending: '-',
awarded: '낙찰',
failed: '유찰',
};
export const AWARD_STATUS_STYLES: Record<AwardStatus, string> = {
pending: 'text-gray-400',
awarded: 'text-blue-600 font-medium',
failed: 'text-red-500',
rejected: '반려',
hold: '보류',
};

View File

@@ -0,0 +1,13 @@
// 목업 재료 목록
export const MOCK_MATERIALS = [
{ value: 'screen', label: '스크린' },
{ value: 'slat', label: '슬랫' },
{ value: 'bending', label: '벤딩' },
{ value: 'jointbar', label: '조인트바' },
];
// 목업 공과 목록
export const MOCK_EXPENSES = [
{ value: 'public_1', label: '공과비 V' },
{ value: 'public_2', label: '공과비 A' },
];

View File

@@ -0,0 +1,4 @@
// 금액 포맷팅
export function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}

View File

@@ -0,0 +1,2 @@
export * from './constants';
export * from './formatters';

View File

@@ -0,0 +1,784 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Plus, X, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type {
HandoverReportDetail,
HandoverReportFormData,
HandoverStatus,
ConstructionManager,
} from './types';
import {
HANDOVER_STATUS_LABELS,
CONSTRUCTION_PM_OPTIONS,
MANAGER_OPTIONS,
getEmptyHandoverReportFormData,
handoverReportDetailToFormData,
} from './types';
import { updateHandoverReport, deleteHandoverReport } from './actions';
import { HandoverReportDocumentModal } from './modals/HandoverReportDocumentModal';
import {
ElectronicApprovalModal,
type ElectronicApproval,
getEmptyElectronicApproval,
} from '../common';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
interface HandoverReportDetailFormProps {
mode: 'view' | 'edit';
reportId: string;
initialData?: HandoverReportDetail;
}
export default function HandoverReportDetailForm({
mode,
reportId,
initialData,
}: HandoverReportDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
// 폼 데이터
const [formData, setFormData] = useState<HandoverReportFormData>(
initialData ? handoverReportDetailToFormData(initialData) : getEmptyHandoverReportFormData()
);
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
// 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
// 모달 상태
const [showDocumentModal, setShowDocumentModal] = useState(false);
const [showApprovalModal, setShowApprovalModal] = useState(false);
// 전자결재 데이터
const [approvalData, setApprovalData] = useState<ElectronicApproval>(
getEmptyElectronicApproval()
);
// 네비게이션 핸들러
const handleBack = useCallback(() => {
router.push('/ko/juil/project/contract/handover-report');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/juil/project/contract/handover-report/${reportId}/edit`);
}, [router, reportId]);
const handleCancel = useCallback(() => {
router.push(`/ko/juil/project/contract/handover-report/${reportId}`);
}, [router, reportId]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof HandoverReportFormData, value: string | number | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// 저장 핸들러
const handleSave = useCallback(() => {
setShowSaveDialog(true);
}, []);
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
const result = await updateHandoverReport(reportId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/juil/project/contract/handover-report/${reportId}`);
router.refresh();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, reportId, formData]);
// 삭제 핸들러
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
setIsLoading(true);
try {
const result = await deleteHandoverReport(reportId);
if (result.success) {
toast.success('인수인계보고서가 삭제되었습니다.');
setShowDeleteDialog(false);
router.push('/ko/juil/project/contract/handover-report');
router.refresh();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, reportId]);
// 인수인계보고서 보기 핸들러
const handleViewDocument = useCallback(() => {
setShowDocumentModal(true);
}, []);
// 전자결재 핸들러
const handleApproval = useCallback(() => {
setShowApprovalModal(true);
}, []);
// 전자결재 저장
const handleApprovalSave = useCallback((approval: ElectronicApproval) => {
setApprovalData(approval);
setShowApprovalModal(false);
toast.success('전자결재 정보가 저장되었습니다.');
}, []);
// 공사담당자 추가
const handleAddManager = useCallback(() => {
const newManager: ConstructionManager = {
id: String(Date.now()),
name: '',
nonPerformanceReason: '',
};
setFormData((prev) => ({
...prev,
constructionManagers: [...prev.constructionManagers, newManager],
}));
}, []);
// 공사담당자 삭제
const handleRemoveManager = useCallback((managerId: string) => {
setFormData((prev) => ({
...prev,
constructionManagers: prev.constructionManagers.filter((m) => m.id !== managerId),
}));
}, []);
// 공사담당자 변경
const handleManagerChange = useCallback(
(managerId: string, field: keyof ConstructionManager, value: string | boolean) => {
setFormData((prev) => ({
...prev,
constructionManagers: prev.constructionManagers.map((m) =>
m.id === managerId ? { ...m, [field]: value } : m
),
}));
},
[]
);
// 장비 외 실행금액 변경
const handleEquipmentCostChange = useCallback(
(field: 'shippingCost' | 'highAltitudeWork' | 'publicExpense', value: number) => {
setFormData((prev) => ({
...prev,
externalEquipmentCost: {
...prev.externalEquipmentCost,
[field]: value,
},
}));
},
[]
);
// 계약 ITEM 비고 변경
const handleContractItemRemarkChange = useCallback((itemId: string, remark: string) => {
setFormData((prev) => ({
...prev,
contractItems: prev.contractItems.map((item) =>
item.id === itemId ? { ...item, remark } : item
),
}));
}, []);
// 헤더 액션 버튼
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleViewDocument}>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleApproval}>
</Button>
<Button onClick={handleEdit}></Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
</Button>
</div>
);
return (
<PageLayout>
<PageHeader
title="인수인계보고서 상세"
description="인수인계 정보를 등록하고 관리합니다"
icon={FileText}
onBack={handleBack}
actions={headerActions}
/>
<div className="space-y-6">
{/* 인수인계 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 보고서번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.reportNumber}
onChange={(e) => handleFieldChange('reportNumber', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약담당자 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.contractManagerName}
onChange={(e) => handleFieldChange('contractManagerName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.partnerName}
onChange={(e) => handleFieldChange('partnerName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.siteName}
onChange={(e) => handleFieldChange('siteName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약일자 */}
<div className="space-y-2">
<Label></Label>
<Input
type="date"
value={formData.contractDate}
onChange={(e) => handleFieldChange('contractDate', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 개소 */}
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={formData.totalSites}
onChange={(e) => handleFieldChange('totalSites', parseInt(e.target.value) || 0)}
disabled={isViewMode}
/>
</div>
{/* 계약기간 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Input
type="date"
value={formData.contractStartDate}
onChange={(e) => handleFieldChange('contractStartDate', e.target.value)}
disabled={isViewMode}
/>
<span>~</span>
<Input
type="date"
value={formData.contractEndDate}
onChange={(e) => handleFieldChange('contractEndDate', e.target.value)}
disabled={isViewMode}
/>
</div>
</div>
{/* 계약금액 (공급가액) */}
<div className="space-y-2">
<Label> ()</Label>
<Input
type="text"
value={formatAmount(formData.contractAmount)}
onChange={(e) => {
const value = e.target.value.replace(/[^0-9]/g, '');
handleFieldChange('contractAmount', parseInt(value) || 0);
}}
disabled={isViewMode}
/>
</div>
{/* 공사PM */}
<div className="space-y-2">
<Label>PM</Label>
<Select
value={formData.constructionPMId}
onValueChange={(value) => {
handleFieldChange('constructionPMId', value);
const pm = CONSTRUCTION_PM_OPTIONS.find((p) => p.value === value);
if (pm) {
handleFieldChange('constructionPMName', pm.label);
}
}}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{CONSTRUCTION_PM_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<RadioGroup
value={formData.status}
onValueChange={(value) => handleFieldChange('status', value as HandoverStatus)}
disabled={isViewMode}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pending" id="pending" />
<Label htmlFor="pending" className="font-normal cursor-pointer">
{HANDOVER_STATUS_LABELS.pending}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="completed" id="completed" />
<Label htmlFor="completed" className="font-normal cursor-pointer">
{HANDOVER_STATUS_LABELS.completed}
</Label>
</div>
</RadioGroup>
</div>
</div>
</CardContent>
</Card>
{/* 공사담당자 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg"></CardTitle>
{isEditMode && (
<Button variant="outline" size="sm" onClick={handleAddManager}>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="w-[200px]"></TableHead>
<TableHead> </TableHead>
{isEditMode && <TableHead className="w-[60px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{formData.constructionManagers.length === 0 ? (
<TableRow>
<TableCell colSpan={isEditMode ? 4 : 3} className="text-center text-muted-foreground py-8">
.
</TableCell>
</TableRow>
) : (
formData.constructionManagers.map((manager, index) => (
<TableRow key={manager.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
{isEditMode ? (
<Select
value={manager.name}
onValueChange={(value) => handleManagerChange(manager.id, 'name', value)}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="이름" />
</SelectTrigger>
<SelectContent>
{MANAGER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.label}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
manager.name
)}
</TableCell>
<TableCell>
{isEditMode ? (
<Input
value={manager.nonPerformanceReason}
onChange={(e) =>
handleManagerChange(manager.id, 'nonPerformanceReason', e.target.value)
}
placeholder="미이행 사유 입력"
/>
) : (
manager.nonPerformanceReason || '-'
)}
</TableCell>
{isEditMode && (
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleRemoveManager(manager.id)}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 계약 ITEM */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> ITEM</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{formData.contractItems.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
ITEM이 .
</TableCell>
</TableRow>
) : (
formData.contractItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-center">{item.no}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.product}</TableCell>
<TableCell className="text-right">{formatAmount(item.quantity)}</TableCell>
<TableCell>
{isEditMode ? (
<Input
value={item.remark}
onChange={(e) => handleContractItemRemarkChange(item.id, e.target.value)}
placeholder="비고 입력"
/>
) : (
item.remark || '-'
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 상세 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* 2차 배관 유무 / 도장 & 코킹 유무 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 2차 배관 유무 */}
<div className="space-y-2">
<Label>2 </Label>
<div className="flex items-center gap-4">
<RadioGroup
value={formData.hasSecondaryPiping ? 'included' : 'not_included'}
onValueChange={(value) => {
handleFieldChange('hasSecondaryPiping', value === 'included');
if (value !== 'included') {
handleFieldChange('secondaryPipingNote', '');
}
}}
disabled={isViewMode}
className="flex gap-4 shrink-0"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="not_included" id="piping_not" />
<Label htmlFor="piping_not" className="font-normal cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="included" id="piping_yes" />
<Label htmlFor="piping_yes" className="font-normal cursor-pointer">
</Label>
</div>
</RadioGroup>
<Input
value={formData.secondaryPipingNote}
onChange={(e) => handleFieldChange('secondaryPipingNote', e.target.value)}
disabled={isViewMode || !formData.hasSecondaryPiping}
placeholder="2차 배관 내용 입력"
className={`flex-1 transition-opacity duration-200 ${
formData.hasSecondaryPiping ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
/>
</div>
</div>
{/* 도장 & 코킹 유무 */}
<div className="space-y-2">
<Label> & </Label>
<div className="flex items-center gap-4">
<RadioGroup
value={formData.hasCoating ? 'included' : 'not_included'}
onValueChange={(value) => {
handleFieldChange('hasCoating', value === 'included');
if (value !== 'included') {
handleFieldChange('coatingNote', '');
}
}}
disabled={isViewMode}
className="flex gap-4 shrink-0"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="not_included" id="coating_not" />
<Label htmlFor="coating_not" className="font-normal cursor-pointer">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="included" id="coating_yes" />
<Label htmlFor="coating_yes" className="font-normal cursor-pointer">
</Label>
</div>
</RadioGroup>
<Input
value={formData.coatingNote}
onChange={(e) => handleFieldChange('coatingNote', e.target.value)}
disabled={isViewMode || !formData.hasCoating}
placeholder="도장 & 코킹 내용 입력"
className={`flex-1 transition-opacity duration-200 ${
formData.hasCoating ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
/>
</div>
</div>
</div>
{/* 장비 외 실행금액 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
checked={formData.externalEquipmentCost.shippingCost > 0}
onCheckedChange={(checked) =>
handleEquipmentCostChange('shippingCost', checked ? 1500000 : 0)
}
disabled={isViewMode}
/>
<Label className="font-normal"></Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={formData.externalEquipmentCost.highAltitudeWork > 0}
onCheckedChange={(checked) =>
handleEquipmentCostChange('highAltitudeWork', checked ? 800000 : 0)
}
disabled={isViewMode}
/>
<Label className="font-normal"></Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={formData.externalEquipmentCost.publicExpense > 0}
onCheckedChange={(checked) =>
handleEquipmentCostChange('publicExpense', checked ? 10000000 : 0)
}
disabled={isViewMode}
/>
<Label className="font-normal"></Label>
</div>
</div>
</div>
{/* 특이사항 */}
<div className="space-y-2">
<Label></Label>
<Textarea
value={formData.specialNotes}
onChange={(e) => handleFieldChange('specialNotes', e.target.value)}
disabled={isViewMode}
rows={4}
placeholder="특이사항을 입력하세요"
/>
</div>
{/* 녹음 버튼 */}
{isEditMode && (
<div className="flex justify-end">
<Button variant="outline"></Button>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 인수인계보고서 보기 모달 */}
{initialData && (
<HandoverReportDocumentModal
open={showDocumentModal}
onOpenChange={setShowDocumentModal}
report={initialData}
/>
)}
{/* 전자결재 모달 */}
<ElectronicApprovalModal
isOpen={showApprovalModal}
onClose={() => setShowApprovalModal(false)}
approval={approvalData}
onSave={handleApprovalSave}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,490 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Clock, CheckCircle, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import type { HandoverReport, HandoverReportStats } from './types';
import {
REPORT_STATUS_OPTIONS,
REPORT_SORT_OPTIONS,
HANDOVER_STATUS_LABELS,
HANDOVER_STATUS_STYLES,
} from './types';
import {
getHandoverReportList,
getHandoverReportStats,
} from './actions';
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'reportNumber', label: '보고서번호', className: 'w-[100px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
{ key: 'contractManager', label: '계약담당자', className: 'w-[100px] text-center' },
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px] text-center' },
{ key: 'totalSites', label: '총 개소', className: 'w-[80px] text-center' },
{ key: 'contractAmount', label: '계약금액(공급가액)', className: 'w-[140px] text-right' },
{ key: 'contractPeriod', label: '계약기간', className: 'w-[180px] text-center' },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 목업 거래처 목록
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: 'partner1', label: '주식회사 한빛' },
{ value: 'partner2', label: '대성건설' },
{ value: 'partner3', label: '삼성물산' },
{ value: 'partner4', label: 'LG전자' },
];
// 목업 계약담당자 목록
const MOCK_CONTRACT_MANAGERS: MultiSelectOption[] = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
];
// 목업 공사PM 목록
const MOCK_CONSTRUCTION_PMS: MultiSelectOption[] = [
{ value: 'kim', label: '김PM' },
{ value: 'lee', label: '이PM' },
{ value: 'park', label: '박PM' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
}
// 계약기간 포맷팅
function formatPeriod(startDate: string | null, endDate: string | null): string {
const start = formatDate(startDate);
const end = formatDate(endDate);
if (start === '-' && end === '-') return '-';
return `${start} ~ ${end}`;
}
interface HandoverReportListClientProps {
initialData?: HandoverReport[];
initialStats?: HandoverReportStats;
}
export default function HandoverReportListClient({
initialData = [],
initialStats,
}: HandoverReportListClientProps) {
const router = useRouter();
// 상태
const [reports, setReports] = useState<HandoverReport[]>(initialData);
const [stats, setStats] = useState<HandoverReportStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [contractManagerFilters, setContractManagerFilters] = useState<string[]>([]);
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('contractDateDesc');
const [startDate, setStartDate] = useState<string>('2025-09-01');
const [endDate, setEndDate] = useState<string>('2025-09-03');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getHandoverReportList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getHandoverReportStats(),
]);
if (listResult.success && listResult.data) {
setReports(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredReports = useMemo(() => {
return reports.filter((report) => {
// 상태 탭 필터
if (activeStatTab === 'pending' && report.status !== 'pending') return false;
if (activeStatTab === 'completed' && report.status !== 'completed') return false;
// 거래처 필터
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(report.partnerId)) return false;
}
// 계약담당자 필터
if (contractManagerFilters.length > 0) {
if (!contractManagerFilters.includes(report.contractManagerId)) return false;
}
// 공사PM 필터
if (constructionPMFilters.length > 0) {
if (!constructionPMFilters.includes(report.constructionPMId || '')) return false;
}
// 상태 필터
if (statusFilter !== 'all' && report.status !== statusFilter) return false;
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
report.reportNumber.toLowerCase().includes(search) ||
report.partnerName.toLowerCase().includes(search) ||
report.siteName.toLowerCase().includes(search)
);
}
return true;
});
}, [reports, activeStatTab, partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, searchValue]);
// 정렬
const sortedReports = useMemo(() => {
const sorted = [...filteredReports];
switch (sortBy) {
case 'contractDateDesc':
sorted.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
});
break;
case 'contractDateAsc':
sorted.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
});
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'siteNameAsc':
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
break;
case 'siteNameDesc':
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
break;
}
return sorted;
}, [filteredReports, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedReports.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedReports.slice(start, start + itemsPerPage);
}, [sortedReports, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((r) => r.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(report: HandoverReport) => {
router.push(`/ko/juil/project/contract/handover-report/${report.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, reportId: string) => {
e.stopPropagation();
router.push(`/ko/juil/project/contract/handover-report/${reportId}/edit`);
},
[router]
);
// 테이블 행 렌더링
const renderTableRow = useCallback(
(report: HandoverReport, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(report.id);
return (
<TableRow
key={report.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(report)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(report.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{report.reportNumber}</TableCell>
<TableCell>{report.partnerName}</TableCell>
<TableCell>{report.siteName}</TableCell>
<TableCell className="text-center">{report.contractManagerName}</TableCell>
<TableCell className="text-center">{report.constructionPMName || '-'}</TableCell>
<TableCell className="text-center">{report.totalSites}</TableCell>
<TableCell className="text-right">{formatAmount(report.contractAmount)}</TableCell>
<TableCell className="text-center">
{formatPeriod(report.contractStartDate, report.contractEndDate)}
</TableCell>
<TableCell className="text-center">
<span className={HANDOVER_STATUS_STYLES[report.status]}>
{HANDOVER_STATUS_LABELS[report.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, report.id)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(report: HandoverReport, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={report.siteName}
subtitle={report.reportNumber}
badge={HANDOVER_STATUS_LABELS[report.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(report)}
details={[
{ label: '거래처', value: report.partnerName },
{ label: '계약금액', value: `${formatAmount(report.contractAmount)}` },
{ label: '계약담당자', value: report.contractManagerName },
{ label: '총 개소', value: `${report.totalSites}개소` },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜 필터)
const headerActions = (
<div className="flex flex-col gap-2 w-full">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
</div>
);
// Stats 카드 데이터 (전체 인수인계보고서, 인수인계대기, 인수인계완료)
const statsCardsData: StatCard[] = [
{
label: '전체 인수인계보고서',
value: stats?.total ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '인수인계대기',
value: stats?.pending ?? 0,
icon: Clock,
iconColor: 'text-orange-500',
onClick: () => setActiveStatTab('pending'),
isActive: activeStatTab === 'pending',
},
{
label: '인수인계완료',
value: stats?.completed ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('completed'),
isActive: activeStatTab === 'completed',
},
];
// 테이블 헤더 액션
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedReports.length}
</span>
{/* 거래처 필터 */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[130px]"
/>
{/* 계약담당자 필터 */}
<MultiSelectCombobox
options={MOCK_CONTRACT_MANAGERS}
value={contractManagerFilters}
onChange={setContractManagerFilters}
placeholder="계약담당자"
searchPlaceholder="계약담당자 검색..."
className="w-[130px]"
/>
{/* 공사PM 필터 */}
<MultiSelectCombobox
options={MOCK_CONSTRUCTION_PMS}
value={constructionPMFilters}
onChange={setConstructionPMFilters}
placeholder="공사PM"
searchPlaceholder="공사PM 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{REPORT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="최신순 (계약시작일)" />
</SelectTrigger>
<SelectContent>
{REPORT_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="인수인계보고서관리"
description="계약이 종료 상태 시 인수인계보고서 자동 등록"
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="보고서번호, 거래처, 현장명 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedReports}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
pagination={{
currentPage,
totalPages,
totalItems: sortedReports.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
</>
);
}

View File

@@ -0,0 +1,400 @@
'use server';
import type { HandoverReport, HandoverReportStats, HandoverReportDetail, HandoverReportFormData } from './types';
// 목업 데이터
const MOCK_REPORTS: HandoverReport[] = [
{
id: '1',
reportNumber: '123123',
partnerName: '통신공사',
siteName: '서울역사 통신공사',
contractManagerName: '홍길동',
constructionPMName: '김PM',
totalSites: 21,
contractAmount: 105800000,
contractStartDate: '2025-12-12',
contractEndDate: '2026-12-12',
status: 'pending',
contractId: '1',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
},
{
id: '2',
reportNumber: '123124',
partnerName: '야사건설',
siteName: '부산항 건설현장',
contractManagerName: '김철수',
constructionPMName: '이PM',
totalSites: 15,
contractAmount: 10500000,
contractStartDate: '2025-11-01',
contractEndDate: '2026-11-01',
status: 'completed',
contractId: '2',
createdAt: '2025-01-02',
updatedAt: '2025-01-02',
},
{
id: '3',
reportNumber: '123125',
partnerName: '여의건설',
siteName: '인천공항 확장공사',
contractManagerName: '이영희',
constructionPMName: '박PM',
totalSites: 30,
contractAmount: 10000000,
contractStartDate: '2025-10-15',
contractEndDate: '2026-10-15',
status: 'pending',
contractId: '3',
createdAt: '2025-01-03',
updatedAt: '2025-01-03',
},
{
id: '4',
reportNumber: '123126',
partnerName: '통신공사',
siteName: '대전역 리모델링',
contractManagerName: '홍길동',
constructionPMName: '김PM',
totalSites: 18,
contractAmount: 10000000,
contractStartDate: '2025-09-20',
contractEndDate: '2026-03-20',
status: 'completed',
contractId: '4',
createdAt: '2025-01-04',
updatedAt: '2025-01-04',
},
{
id: '5',
reportNumber: '123127',
partnerName: '야사건설',
siteName: '광주 신축현장',
contractManagerName: '김철수',
constructionPMName: '이PM',
totalSites: 17,
contractAmount: 10500000,
contractStartDate: '2025-08-01',
contractEndDate: '2026-08-01',
status: 'pending',
contractId: '5',
createdAt: '2025-01-05',
updatedAt: '2025-01-05',
},
{
id: '6',
reportNumber: '123128',
partnerName: '여의건설',
siteName: '세종시 행정타운',
contractManagerName: '이영희',
constructionPMName: '박PM',
totalSites: 25,
contractAmount: 100000000,
contractStartDate: '2025-07-15',
contractEndDate: '2026-07-15',
status: 'completed',
contractId: '6',
createdAt: '2025-01-06',
updatedAt: '2025-01-06',
},
{
id: '7',
reportNumber: '123129',
partnerName: '통신공사',
siteName: '제주 관광단지',
contractManagerName: '홍길동',
constructionPMName: null,
totalSites: 12,
contractAmount: 105800000,
contractStartDate: '2025-06-01',
contractEndDate: '2026-06-01',
status: 'pending',
contractId: '7',
createdAt: '2025-01-07',
updatedAt: '2025-01-07',
},
];
interface GetHandoverReportListParams {
size?: number;
page?: number;
startDate?: string;
endDate?: string;
}
interface GetHandoverReportListResult {
success: boolean;
data?: {
items: HandoverReport[];
total: number;
page: number;
size: number;
};
error?: string;
}
export async function getHandoverReportList(
params: GetHandoverReportListParams = {}
): Promise<GetHandoverReportListResult> {
try {
// 실제 API 호출 시 여기에 구현
// const response = await fetch(`/api/v1/handover-reports?...`);
// 목업 데이터 반환
return {
success: true,
data: {
items: MOCK_REPORTS,
total: MOCK_REPORTS.length,
page: params.page || 1,
size: params.size || 20,
},
};
} catch (error) {
console.error('Failed to fetch handover report list:', error);
return {
success: false,
error: '인수인계보고서 목록을 불러오는데 실패했습니다.',
};
}
}
interface GetHandoverReportStatsResult {
success: boolean;
data?: HandoverReportStats;
error?: string;
}
export async function getHandoverReportStats(): Promise<GetHandoverReportStatsResult> {
try {
// 실제 API 호출 시 여기에 구현
// 목업 통계 반환
const pending = MOCK_REPORTS.filter(r => r.status === 'pending').length;
const completed = MOCK_REPORTS.filter(r => r.status === 'completed').length;
return {
success: true,
data: {
total: MOCK_REPORTS.length,
pending,
completed,
},
};
} catch (error) {
console.error('Failed to fetch handover report stats:', error);
return {
success: false,
error: '통계를 불러오는데 실패했습니다.',
};
}
}
interface DeleteHandoverReportResult {
success: boolean;
error?: string;
}
export async function deleteHandoverReport(id: string): Promise<DeleteHandoverReportResult> {
try {
// 실제 API 호출 시 여기에 구현
console.log('Deleting handover report:', id);
return {
success: true,
};
} catch (error) {
console.error('Failed to delete handover report:', error);
return {
success: false,
error: '삭제에 실패했습니다.',
};
}
}
interface DeleteHandoverReportsResult {
success: boolean;
deletedCount?: number;
error?: string;
}
export async function deleteHandoverReports(ids: string[]): Promise<DeleteHandoverReportsResult> {
try {
// 실제 API 호출 시 여기에 구현
console.log('Deleting handover reports:', ids);
return {
success: true,
deletedCount: ids.length,
};
} catch (error) {
console.error('Failed to delete handover reports:', error);
return {
success: false,
error: '일괄 삭제에 실패했습니다.',
};
}
}
// 목업 상세 데이터
const MOCK_REPORT_DETAILS: Record<string, HandoverReportDetail> = {
'1': {
id: '1',
reportNumber: '123123',
partnerName: '통신공사',
siteName: '서울역사 통신공사',
contractManagerName: '홍길동',
constructionPMName: '김PM',
constructionPMId: 'pm1',
totalSites: 21,
contractAmount: 105800000,
contractDate: '2025-12-12',
contractStartDate: '2026-01-01',
contractEndDate: '2026-12-10',
status: 'pending',
contractId: '1',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
completionDate: '2026-05-01',
constructionManagers: [
{ id: 'mgr1', name: '홍길동', isNonPerformanceUsed: false },
{ id: 'mgr2', name: '김철수', isNonPerformanceUsed: true },
],
contractItems: [
{ id: 'item1', no: 1, name: '접지방화서터', product: '제품', quantity: 1000, remark: '품질인증적용' },
{ id: 'item2', no: 2, name: '스크린방화서터', product: '제품', quantity: 111, remark: '품질인증적용' },
],
hasSecondaryPiping: true,
secondaryPipingAmount: 1200000,
hasCoating: true,
coatingAmount: 500000,
externalEquipmentCost: {
shippingCost: 1500000,
highAltitudeWork: 800000,
publicExpense: 10000000,
},
specialNotes: '특이사항 내용이 여기에 표시됩니다.',
},
'2': {
id: '2',
reportNumber: '123124',
partnerName: '야사건설',
siteName: '부산항 건설현장',
contractManagerName: '김철수',
constructionPMName: '이PM',
constructionPMId: 'pm2',
totalSites: 15,
contractAmount: 10500000,
contractDate: '2025-11-01',
contractStartDate: '2025-11-01',
contractEndDate: '2026-11-01',
status: 'completed',
contractId: '2',
createdAt: '2025-01-02',
updatedAt: '2025-01-02',
completionDate: '2026-04-01',
constructionManagers: [
{ id: 'mgr3', name: '이영희', isNonPerformanceUsed: false },
],
contractItems: [
{ id: 'item3', no: 1, name: '방화문', product: '제품A', quantity: 500, remark: '' },
],
hasSecondaryPiping: false,
secondaryPipingAmount: 0,
hasCoating: false,
coatingAmount: 0,
externalEquipmentCost: {
shippingCost: 500000,
highAltitudeWork: 0,
publicExpense: 2000000,
},
specialNotes: '',
},
};
interface GetHandoverReportDetailResult {
success: boolean;
data?: HandoverReportDetail;
error?: string;
}
export async function getHandoverReportDetail(id: string): Promise<GetHandoverReportDetailResult> {
try {
// 실제 API 호출 시 여기에 구현
// const response = await fetch(`/api/v1/handover-reports/${id}`);
const detail = MOCK_REPORT_DETAILS[id];
if (!detail) {
// 목록 데이터에서 기본 상세 생성
const report = MOCK_REPORTS.find(r => r.id === id);
if (report) {
const generatedDetail: HandoverReportDetail = {
...report,
contractDate: report.contractStartDate,
constructionPMId: 'pm1',
completionDate: null,
constructionManagers: [],
contractItems: [],
hasSecondaryPiping: false,
secondaryPipingAmount: 0,
hasCoating: false,
coatingAmount: 0,
externalEquipmentCost: {
shippingCost: 0,
highAltitudeWork: 0,
publicExpense: 0,
},
specialNotes: '',
};
return {
success: true,
data: generatedDetail,
};
}
return {
success: false,
error: '인수인계보고서를 찾을 수 없습니다.',
};
}
return {
success: true,
data: detail,
};
} catch (error) {
console.error('Failed to fetch handover report detail:', error);
return {
success: false,
error: '인수인계보고서 상세 정보를 불러오는데 실패했습니다.',
};
}
}
interface UpdateHandoverReportResult {
success: boolean;
error?: string;
}
export async function updateHandoverReport(
id: string,
data: HandoverReportFormData
): Promise<UpdateHandoverReportResult> {
try {
// 실제 API 호출 시 여기에 구현
console.log('Updating handover report:', id, data);
return {
success: true,
};
} catch (error) {
console.error('Failed to update handover report:', error);
return {
success: false,
error: '수정에 실패했습니다.',
};
}
}

View File

@@ -0,0 +1,5 @@
export { default as HandoverReportListClient } from './HandoverReportListClient';
export { default as HandoverReportDetailForm } from './HandoverReportDetailForm';
export * from './types';
export * from './actions';
export * from './modals';

View File

@@ -0,0 +1,308 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Edit,
Trash2,
Printer,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import { useRouter } from 'next/navigation';
import { printArea } from '@/lib/print-utils';
import type { HandoverReportDetail } from '../types';
// 금액 포맷팅
function formatAmount(amount: number | undefined | null): string {
if (amount === undefined || amount === null) return '0';
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 날짜 포맷팅 (년월)
function formatYearMonth(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return `${date.getFullYear()}${date.getMonth() + 1}`;
}
// 날짜 포맷팅 (전체)
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
interface HandoverReportDocumentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
report: HandoverReportDetail;
}
export function HandoverReportDocumentModal({
open,
onOpenChange,
report,
}: HandoverReportDocumentModalProps) {
const router = useRouter();
// 수정
const handleEdit = () => {
onOpenChange(false);
router.push(`/ko/juil/project/contract/handover-report/${report.id}/edit`);
};
// 삭제
const handleDelete = () => {
toast.info('삭제 기능은 준비 중입니다.');
};
// 인쇄
const handlePrint = () => {
printArea({ title: '인수인계보고서 인쇄' });
};
// 계약 ITEM 행 수 계산 (최소 1행)
const contractItemsCount = Math.max(report.contractItems.length, 1);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex justify-between items-start mb-6">
{/* 좌측: 제목 및 문서정보 */}
<div>
<h1 className="text-2xl font-bold mb-2"></h1>
<div className="text-sm text-gray-600">
: {report.reportNumber} | : {formatDate(report.createdAt)}
</div>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse border border-gray-300 text-sm">
<tbody>
<tr>
<th rowSpan={3} className="border border-gray-300 px-2 py-1 bg-gray-50 text-center w-8 align-middle">
<span className="writing-vertical"><br /></span>
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16"></th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16"></th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16"></th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16"></th>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2 text-center h-10"></td>
<td className="border border-gray-300 px-3 py-2 text-center h-10"></td>
<td className="border border-gray-300 px-3 py-2 text-center h-10"></td>
<td className="border border-gray-300 px-3 py-2 text-center h-10"></td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500"></td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500"></td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500"></td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500"></td>
</tr>
</tbody>
</table>
</div>
{/* 통합 테이블 - 기획서 구조 100% 반영 */}
<table className="w-full border-collapse border border-gray-300 text-sm">
<tbody>
{/* 현장명 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left w-40 font-medium"></th>
<td className="border border-gray-300 px-4 py-3">{report.siteName || '-'}</td>
</tr>
{/* 거래처 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium"></th>
<td className="border border-gray-300 px-4 py-3">{report.partnerName || '-'}</td>
</tr>
{/* 준공 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium"></th>
<td className="border border-gray-300 px-4 py-3">{formatYearMonth(report.completionDate)}</td>
</tr>
{/* 계약금액 (공급가액) */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium"> ()</th>
<td className="border border-gray-300 px-4 py-3"> {formatAmount(report.contractAmount)}</td>
</tr>
{/* 계약 ITEM - 기획서: 구분, 수량, 비고 3컬럼 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle"> ITEM</th>
<td className="border border-gray-300 p-0">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-50">
<th className="border-b border-gray-300 px-4 py-2 text-left font-medium"></th>
<th className="border-b border-l border-gray-300 px-4 py-2 text-center font-medium w-24"></th>
<th className="border-b border-l border-gray-300 px-4 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{report.contractItems.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-3 text-center text-gray-400">-</td>
</tr>
) : (
report.contractItems.map((item, idx) => (
<tr key={item.id}>
<td className={`px-4 py-2 ${idx > 0 ? 'border-t border-gray-300' : ''}`}>
{item.name || '-'}
</td>
<td className={`px-4 py-2 text-center border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
{formatAmount(item.quantity)}
</td>
<td className={`px-4 py-2 border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
{item.remark || '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</td>
</tr>
{/* 집행유무 - 기획서: 2차 배관 유무, 도장 & 코킹 유무 + 금액 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle"></th>
<td className="border border-gray-300 p-0">
<table className="w-full border-collapse text-sm">
<tbody>
<tr>
<th className="px-4 py-2 bg-gray-50 text-left font-medium w-40">2 </th>
<td className="px-4 py-2">
{report.hasSecondaryPiping
? `포함 (${formatAmount(report.secondaryPipingAmount)})`
: '미포함'}
</td>
</tr>
<tr>
<th className="border-t border-gray-300 px-4 py-2 bg-gray-50 text-left font-medium"> & </th>
<td className="border-t border-gray-300 px-4 py-2">
{report.hasCoating
? `포함 (${formatAmount(report.coatingAmount)})`
: '미포함'}
</td>
</tr>
</tbody>
</table>
</td>
</tr>
{/* 장비 외 실행금액 - 기획서: 운반비, 양중장비, 공과금 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle"> </th>
<td className="border border-gray-300 px-4 py-3">
<div className="space-y-1">
<div> : {formatAmount(report.externalEquipmentCost?.shippingCost)}</div>
<div> : {formatAmount(report.externalEquipmentCost?.highAltitudeWork)}</div>
<div> : {formatAmount(report.externalEquipmentCost?.publicExpense)}</div>
</div>
</td>
</tr>
{/* 특이사항 - 기획서: 내용 단일 필드 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle"></th>
<td className="border border-gray-300 px-4 py-3 whitespace-pre-wrap">
{report.specialNotes || '-'}
</td>
</tr>
{/* 공사 담당자 - 기획서: 성명, 서명, 미이행 사유 3컬럼 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle"> </th>
<td className="border border-gray-300 p-0">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-50">
<th className="border-b border-gray-300 px-4 py-2 text-center font-medium w-28"></th>
<th className="border-b border-l border-gray-300 px-4 py-2 text-center font-medium"></th>
<th className="border-b border-l border-gray-300 px-4 py-2 text-center font-medium w-32"> </th>
</tr>
</thead>
<tbody>
{report.constructionManagers.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-3 text-center text-gray-400">-</td>
</tr>
) : (
report.constructionManagers.map((manager, idx) => (
<tr key={manager.id}>
<td className={`px-4 py-2 text-center ${idx > 0 ? 'border-t border-gray-300' : ''}`}>
{manager.name || '-'}
</td>
<td className={`px-4 py-2 text-center border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
{manager.signature || ''}
</td>
<td className={`px-4 py-2 text-center border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
{manager.nonPerformanceReason || ''}
</td>
</tr>
))
)}
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { HandoverReportDocumentModal } from './HandoverReportDocumentModal';

View File

@@ -0,0 +1,209 @@
// 인수인계보고서 타입 정의
// 인수인계 상태
export type HandoverStatus = 'pending' | 'completed';
// 인수인계보고서 (목록용)
export interface HandoverReport {
id: string;
reportNumber: string; // 보고서번호
partnerName: string; // 거래처
siteName: string; // 현장명
contractManagerName: string; // 계약담당자
constructionPMName: string | null; // 공사PM
totalSites: number; // 총 개소
contractAmount: number; // 계약금액(공급가액)
contractStartDate: string | null; // 계약시작일
contractEndDate: string | null; // 계약종료일
status: HandoverStatus; // 상태 (인수인계대기/인수인계완료)
contractId: string; // 연결된 계약 ID
createdAt: string;
updatedAt: string;
}
// 공사담당자
export interface ConstructionManager {
id: string;
name: string;
nonPerformanceReason: string; // 미이행 사유
signature?: string | null; // 서명 (보고서 보기용)
}
// 계약 ITEM
export interface ContractItem {
id: string;
no: number; // 번호
name: string; // 명칭
product: string; // 제품
quantity: number; // 수량
remark: string; // 비고
}
// 장비 외 실행금액
export interface ExternalEquipmentCost {
shippingCost: number; // 운반비
highAltitudeWork: number; // 고소작업대
publicExpense: number; // 공과잡
}
// 인수인계보고서 상세
export interface HandoverReportDetail extends HandoverReport {
contractDate: string | null; // 계약일자
constructionPMId: string | null; // 공사PM ID
constructionManagers: ConstructionManager[]; // 공사담당자 목록
contractItems: ContractItem[]; // 계약 ITEM 목록
hasSecondaryPiping: boolean; // 2차 배관 유무
secondaryPipingAmount: number; // 2차 배관 금액
secondaryPipingNote: string; // 2차 배관 비고 (포함 시 입력)
hasCoating: boolean; // 도장 & 코킹 유무
coatingAmount: number; // 도장 & 코킹 금액
coatingNote: string; // 도장 & 코킹 비고 (포함 시 입력)
externalEquipmentCost: ExternalEquipmentCost; // 장비 외 실행금액
specialNotes: string; // 특이사항
completionDate: string | null; // 준공일
}
// 인수인계보고서 폼 데이터
export interface HandoverReportFormData {
reportNumber: string;
partnerName: string;
siteName: string;
contractManagerName: string;
contractDate: string;
totalSites: number;
contractStartDate: string;
contractEndDate: string;
contractAmount: number;
constructionPMId: string;
constructionPMName: string;
status: HandoverStatus;
constructionManagers: ConstructionManager[];
contractItems: ContractItem[];
hasSecondaryPiping: boolean;
secondaryPipingNote: string;
hasCoating: boolean;
coatingNote: string;
externalEquipmentCost: ExternalEquipmentCost;
specialNotes: string;
}
// 기본 폼 데이터 생성
export function getEmptyHandoverReportFormData(): HandoverReportFormData {
return {
reportNumber: '',
partnerName: '',
siteName: '',
contractManagerName: '',
contractDate: '',
totalSites: 0,
contractStartDate: '',
contractEndDate: '',
contractAmount: 0,
constructionPMId: '',
constructionPMName: '',
status: 'pending',
constructionManagers: [],
contractItems: [],
hasSecondaryPiping: false,
secondaryPipingNote: '',
hasCoating: false,
coatingNote: '',
externalEquipmentCost: {
shippingCost: 0,
highAltitudeWork: 0,
publicExpense: 0,
},
specialNotes: '',
};
}
// 상세 데이터를 폼 데이터로 변환
export function handoverReportDetailToFormData(detail: HandoverReportDetail): HandoverReportFormData {
return {
reportNumber: detail.reportNumber,
partnerName: detail.partnerName,
siteName: detail.siteName,
contractManagerName: detail.contractManagerName,
contractDate: detail.contractDate || '',
totalSites: detail.totalSites,
contractStartDate: detail.contractStartDate || '',
contractEndDate: detail.contractEndDate || '',
contractAmount: detail.contractAmount,
constructionPMId: detail.constructionPMId || '',
constructionPMName: detail.constructionPMName || '',
status: detail.status,
constructionManagers: detail.constructionManagers,
contractItems: detail.contractItems,
hasSecondaryPiping: detail.hasSecondaryPiping,
secondaryPipingNote: detail.secondaryPipingNote || '',
hasCoating: detail.hasCoating,
coatingNote: detail.coatingNote || '',
externalEquipmentCost: detail.externalEquipmentCost,
specialNotes: detail.specialNotes,
};
}
// 공사PM 목록 (목업)
export const CONSTRUCTION_PM_OPTIONS = [
{ value: 'pm1', label: '김PM' },
{ value: 'pm2', label: '이PM' },
{ value: 'pm3', label: '박PM' },
{ value: 'pm4', label: '최PM' },
];
// 담당자 목록 (목업)
export const MANAGER_OPTIONS = [
{ value: 'mgr1', label: '홍길동' },
{ value: 'mgr2', label: '김철수' },
{ value: 'mgr3', label: '이영희' },
{ value: 'mgr4', label: '박민수' },
];
// 통계
export interface HandoverReportStats {
total: number; // 전체 인수인계보고서
pending: number; // 인수인계대기
completed: number; // 인수인계완료
}
// 상태 필터 옵션
export const REPORT_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'pending', label: '인수인계대기' },
{ value: 'completed', label: '인수인계완료' },
];
// 정렬 옵션
export const REPORT_SORT_OPTIONS = [
{ value: 'contractDateDesc', label: '최신순 (계약시작일)' },
{ value: 'contractDateAsc', label: '등록순 (계약종료시작일)' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'siteNameAsc', label: '현장명 오름차순' },
{ value: 'siteNameDesc', label: '현장명 내림차순' },
];
// 인수인계 상태 라벨
export const HANDOVER_STATUS_LABELS: Record<HandoverStatus, string> = {
pending: '인수인계대기',
completed: '인수인계완료',
};
// 인수인계 상태 스타일
export const HANDOVER_STATUS_STYLES: Record<HandoverStatus, string> = {
pending: 'inline-flex items-center justify-center rounded-md border border-orange-500 px-2 py-0.5 text-xs font-medium text-orange-600 bg-orange-50',
completed: 'inline-flex items-center justify-center rounded-md border border-green-500 px-2 py-0.5 text-xs font-medium text-green-600 bg-green-50',
};
// 단계 탭 타입
export type StageTab = 'all' | 'estimate_selected' | 'estimate_progress' | 'delivery' | 'installation' | 'other';
// 단계 탭 데이터
export const STAGE_TABS: { key: StageTab; label: string }[] = [
{ key: 'all', label: '상태단건' },
{ key: 'estimate_selected', label: '견적일' },
{ key: 'estimate_progress', label: '발일' },
{ key: 'delivery', label: '발행' },
{ key: 'installation', label: '미체' },
{ key: 'other', label: '옵을' },
];

View File

@@ -0,0 +1,591 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Package, Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { ItemDetail, ItemFormData, ItemType, Specification, OrderType, ItemStatus, OrderItem } from './types';
import {
ITEM_TYPE_OPTIONS,
SPECIFICATION_OPTIONS,
ORDER_TYPE_OPTIONS,
STATUS_OPTIONS,
UNIT_OPTIONS,
} from './constants';
import { getItem, createItem, updateItem, deleteItem, getCategoryOptions } from './actions';
interface ItemDetailClientProps {
itemId?: string;
isEditMode?: boolean;
isNewMode?: boolean;
}
const initialFormData: ItemFormData = {
itemNumber: '',
itemType: '제품',
categoryId: '',
itemName: '',
specification: '인정',
unit: 'SET',
orderType: '경품발주',
status: '사용',
note: '',
orderItems: [],
};
export default function ItemDetailClient({
itemId,
isEditMode = false,
isNewMode = false,
}: ItemDetailClientProps) {
const router = useRouter();
// 모드 상태
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
isNewMode ? 'new' : isEditMode ? 'edit' : 'view'
);
// 폼 데이터
const [formData, setFormData] = useState<ItemFormData>(initialFormData);
const [originalData, setOriginalData] = useState<ItemDetail | null>(null);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<{ id: string; name: string }[]>([]);
// 상태
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// 카테고리 목록 로드
useEffect(() => {
const loadCategories = async () => {
const result = await getCategoryOptions();
if (result.success && result.data) {
setCategoryOptions(result.data);
}
};
loadCategories();
}, []);
// 품목 데이터 로드
useEffect(() => {
if (itemId && !isNewMode) {
const loadItem = async () => {
setIsLoading(true);
try {
const result = await getItem(itemId);
if (result.success && result.data) {
setOriginalData(result.data);
setFormData({
itemNumber: result.data.itemNumber,
itemType: result.data.itemType,
categoryId: result.data.categoryId,
itemName: result.data.itemName,
specification: result.data.specification,
unit: result.data.unit,
orderType: result.data.orderType,
status: result.data.status,
note: result.data.note || '',
orderItems: result.data.orderItems || [],
});
} else {
toast.error(result.error || '품목 정보를 불러오는데 실패했습니다.');
router.push('/ko/juil/order/base-info/items');
}
} catch {
toast.error('품목 정보를 불러오는데 실패했습니다.');
router.push('/ko/juil/order/base-info/items');
} finally {
setIsLoading(false);
}
};
loadItem();
}
}, [itemId, isNewMode, router]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof ItemFormData, value: string | OrderItem[]) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// 발주 항목 추가
const handleAddOrderItem = useCallback(() => {
const newItem: OrderItem = {
id: `new-${Date.now()}`,
label: '',
value: '',
};
setFormData((prev) => ({
...prev,
orderItems: [...prev.orderItems, newItem],
}));
}, []);
// 발주 항목 삭제
const handleRemoveOrderItem = useCallback((id: string) => {
setFormData((prev) => ({
...prev,
orderItems: prev.orderItems.filter((item) => item.id !== id),
}));
}, []);
// 발주 항목 변경
const handleOrderItemChange = useCallback(
(id: string, field: 'label' | 'value', value: string) => {
setFormData((prev) => ({
...prev,
orderItems: prev.orderItems.map((item) =>
item.id === id ? { ...item, [field]: value } : item
),
}));
},
[]
);
// 저장
const handleSave = useCallback(async () => {
// 유효성 검사
if (!formData.itemNumber.trim()) {
toast.error('품목번호를 입력해주세요.');
return;
}
if (!formData.itemName.trim()) {
toast.error('품목명을 입력해주세요.');
return;
}
setIsSaving(true);
try {
if (mode === 'new') {
const result = await createItem(formData);
if (result.success && result.data) {
toast.success('품목이 등록되었습니다.');
router.push(`/ko/juil/order/base-info/items/${result.data.id}`);
} else {
toast.error(result.error || '품목 등록에 실패했습니다.');
}
} else if (mode === 'edit' && itemId) {
const result = await updateItem(itemId, formData);
if (result.success) {
toast.success('품목이 수정되었습니다.');
setMode('view');
// 데이터 다시 로드
const reloadResult = await getItem(itemId);
if (reloadResult.success && reloadResult.data) {
setOriginalData(reloadResult.data);
}
} else {
toast.error(result.error || '품목 수정에 실패했습니다.');
}
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [mode, formData, itemId, router]);
// 삭제
const handleDelete = useCallback(async () => {
if (!itemId) return;
setIsLoading(true);
try {
const result = await deleteItem(itemId);
if (result.success) {
toast.success('품목이 삭제되었습니다.');
router.push('/ko/juil/order/base-info/items');
} else {
toast.error(result.error || '품목 삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
}
}, [itemId, router]);
// 수정 모드 전환
const handleEditMode = useCallback(() => {
setMode('edit');
router.replace(`/ko/juil/order/base-info/items/${itemId}?mode=edit`);
}, [itemId, router]);
// 목록으로 이동
const handleBack = useCallback(() => {
router.push('/ko/juil/order/base-info/items');
}, [router]);
// 취소
const handleCancel = useCallback(() => {
if (mode === 'new') {
router.push('/ko/juil/order/base-info/items');
} else {
setMode('view');
// 원본 데이터로 복원
if (originalData) {
setFormData({
itemNumber: originalData.itemNumber,
itemType: originalData.itemType,
categoryId: originalData.categoryId,
itemName: originalData.itemName,
specification: originalData.specification,
unit: originalData.unit,
orderType: originalData.orderType,
status: originalData.status,
note: originalData.note || '',
orderItems: originalData.orderItems || [],
});
}
router.replace(`/ko/juil/order/base-info/items/${itemId}`);
}
}, [mode, itemId, originalData, router]);
// 읽기 전용 여부
const isReadOnly = mode === 'view';
// 페이지 타이틀
const pageTitle = mode === 'new' ? '품목 등록' : '품목 상세';
// 액션 버튼
const actionButtons = (
<div className="flex items-center gap-2">
{mode === 'view' && (
<>
<Button variant="outline" onClick={handleBack}>
</Button>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
>
</Button>
<Button onClick={handleEditMode}></Button>
</>
)}
{mode === 'edit' && (
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? '저장 중...' : '저장'}
</Button>
</>
)}
{mode === 'new' && (
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? '등록 중...' : '등록'}
</Button>
</>
)}
</div>
);
if (isLoading && !isNewMode) {
return (
<PageLayout>
<PageHeader
title={pageTitle}
description="품목 정보를 등록하고 관리합니다."
icon={Package}
/>
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground"> ...</div>
</div>
</PageLayout>
);
}
return (
<>
<PageLayout>
<PageHeader
title={pageTitle}
description="품목 정보를 등록하고 관리합니다."
icon={Package}
actions={actionButtons}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Row 1: 품목번호, 품목유형 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="itemNumber">
<span className="text-destructive">*</span>
</Label>
<Input
id="itemNumber"
value={formData.itemNumber}
onChange={(e) => handleFieldChange('itemNumber', e.target.value)}
placeholder="품목번호를 입력하세요"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="itemType"></Label>
<Select
value={formData.itemType}
onValueChange={(v) => handleFieldChange('itemType', v as ItemType)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="품목유형 선택" />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 카테고리명 */}
<div className="space-y-2">
<Label htmlFor="categoryId"></Label>
<Select
value={formData.categoryId}
onValueChange={(v) => handleFieldChange('categoryId', v)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{categoryOptions.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Row 3: 품목명, 규격 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="itemName">
<span className="text-destructive">*</span>
</Label>
<Input
id="itemName"
value={formData.itemName}
onChange={(e) => handleFieldChange('itemName', e.target.value)}
placeholder="품목명을 입력하세요"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="specification"></Label>
<Select
value={formData.specification}
onValueChange={(v) => handleFieldChange('specification', v as Specification)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="규격 선택" />
</SelectTrigger>
<SelectContent>
{SPECIFICATION_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 4: 단위, 구분 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="unit"></Label>
<Select
value={formData.unit}
onValueChange={(v) => handleFieldChange('unit', v)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="단위 선택" />
</SelectTrigger>
<SelectContent>
{UNIT_OPTIONS.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="orderType"></Label>
<Select
value={formData.orderType}
onValueChange={(v) => handleFieldChange('orderType', v as OrderType)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="구분 선택" />
</SelectTrigger>
<SelectContent>
{ORDER_TYPE_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 5: 상태, 비고 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(v) => handleFieldChange('status', v as ItemStatus)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Input
id="note"
value={formData.note || ''}
onChange={(e) => handleFieldChange('note', e.target.value)}
placeholder="비고를 입력하세요"
disabled={isReadOnly}
/>
</div>
</div>
</CardContent>
</Card>
{/* 발주 항목 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
{!isReadOnly && (
<Button variant="outline" size="sm" onClick={handleAddOrderItem}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
</CardHeader>
<CardContent>
{formData.orderItems.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
.
{!isReadOnly && ' 추가 버튼을 클릭하여 항목을 추가하세요.'}
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-[1fr_1fr_40px] gap-2 text-sm font-medium text-muted-foreground">
<div></div>
<div> </div>
<div></div>
</div>
{formData.orderItems.map((item) => (
<div key={item.id} className="grid grid-cols-[1fr_1fr_40px] gap-2 items-center">
<Input
value={item.label}
onChange={(e) => handleOrderItemChange(item.id, 'label', e.target.value)}
placeholder="예: 무게"
disabled={isReadOnly}
/>
<Input
value={item.value}
onChange={(e) => handleOrderItemChange(item.id, 'value', e.target.value)}
placeholder="예: 400KG"
disabled={isReadOnly}
/>
{!isReadOnly && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => handleRemoveOrderItem(item.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</PageLayout>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,632 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format, startOfYear, endOfYear } from 'date-fns';
import { Package, Plus, Pencil, Trash2, PackageCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedListTemplateV2, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Item, ItemStats, ItemType, Specification, OrderType, ItemStatus } from './types';
import {
ITEM_TYPE_OPTIONS,
SPECIFICATION_OPTIONS,
ORDER_TYPE_OPTIONS,
STATUS_OPTIONS,
SORT_OPTIONS,
ITEMS_PER_PAGE,
} from './constants';
import { getItemList, deleteItem, deleteItems, getItemStats, getCategoryOptions } from './actions';
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'itemNumber', label: '품목번호', className: 'w-[100px]' },
{ key: 'itemType', label: '품목유형', className: 'w-[90px] text-center' },
{ key: 'category', label: '카테고리', className: 'w-[120px]' },
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
{ key: 'specification', label: '규격', className: 'w-[80px] text-center' },
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
{ key: 'orderType', label: '구분', className: 'w-[100px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
];
interface ItemManagementClientProps {
initialData?: Item[];
initialStats?: ItemStats;
}
export default function ItemManagementClient({
initialData = [],
initialStats,
}: ItemManagementClientProps) {
const router = useRouter();
const today = new Date();
// 날짜 상태 (당해년도 기본값)
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(format(endOfYear(today), 'yyyy-MM-dd'));
// 상태
const [items, setItems] = useState<Item[]>(initialData);
const [stats, setStats] = useState<ItemStats>(initialStats ?? { total: 0, active: 0 });
const [searchValue, setSearchValue] = useState('');
const [itemTypeFilter, setItemTypeFilter] = useState<ItemType | 'all'>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [specificationFilter, setSpecificationFilter] = useState<Specification | 'all'>('all');
const [orderTypeFilter, setOrderTypeFilter] = useState<OrderType | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<ItemStatus | 'all'>('all');
const [sortBy, setSortBy] = useState<'latest' | 'oldest'>('latest');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<{ id: string; name: string }[]>([]);
// 카테고리 목록 로드
useEffect(() => {
const loadCategories = async () => {
const result = await getCategoryOptions();
if (result.success && result.data) {
setCategoryOptions(result.data);
}
};
loadCategories();
}, []);
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getItemList({
size: 1000,
itemType: itemTypeFilter,
categoryId: categoryFilter,
specification: specificationFilter,
orderType: orderTypeFilter,
status: statusFilter,
sortBy,
startDate,
endDate,
}),
getItemStats(),
]);
if (listResult.success && listResult.data) {
setItems(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [itemTypeFilter, categoryFilter, specificationFilter, orderTypeFilter, statusFilter, sortBy, startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredItems = useMemo(() => {
return items.filter((item) => {
// 품목유형 필터
if (itemTypeFilter !== 'all' && item.itemType !== itemTypeFilter) {
return false;
}
// 카테고리 필터
if (categoryFilter !== 'all' && item.categoryId !== categoryFilter) {
return false;
}
// 규격 필터
if (specificationFilter !== 'all' && item.specification !== specificationFilter) {
return false;
}
// 구분 필터
if (orderTypeFilter !== 'all' && item.orderType !== orderTypeFilter) {
return false;
}
// 상태 필터
if (statusFilter !== 'all' && item.status !== statusFilter) {
return false;
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
item.itemNumber.toLowerCase().includes(search) ||
item.itemName.toLowerCase().includes(search) ||
item.categoryName.toLowerCase().includes(search)
);
}
return true;
});
}, [items, itemTypeFilter, categoryFilter, specificationFilter, orderTypeFilter, statusFilter, searchValue]);
// 정렬
const sortedItems = useMemo(() => {
const sorted = [...filteredItems];
if (sortBy === 'oldest') {
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
} else {
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
return sorted;
}, [filteredItems, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedItems.length / ITEMS_PER_PAGE);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * ITEMS_PER_PAGE;
return sortedItems.slice(start, start + ITEMS_PER_PAGE);
}, [sortedItems, currentPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(item: Item) => {
router.push(`/ko/juil/order/base-info/items/${item.id}`);
},
[router]
);
const handleCreate = useCallback(() => {
router.push('/ko/juil/order/base-info/items/new');
}, [router]);
const handleEdit = useCallback(
(e: React.MouseEvent, itemId: string) => {
e.stopPropagation();
router.push(`/ko/juil/order/base-info/items/${itemId}?mode=edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, itemId: string) => {
e.stopPropagation();
setDeleteTargetId(itemId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteItem(deleteTargetId);
if (result.success) {
toast.success('품목이 삭제되었습니다.');
setItems((prev) => prev.filter((item) => item.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
// 통계 재조회
const statsResult = await getItemStats();
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteItems(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
// 상태 배지 색상
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case '승인':
case '사용':
return 'default';
case '작업':
return 'secondary';
case '중지':
return 'destructive';
default:
return 'outline';
}
};
// 테이블 행 렌더링
const renderTableRow = useCallback(
(item: Item, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(item.id);
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(item.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{item.itemNumber}</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{item.itemType}</Badge>
</TableCell>
<TableCell>{item.categoryName}</TableCell>
<TableCell className="font-medium">{item.itemName}</TableCell>
<TableCell className="text-center">{item.specification}</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-center">
<Badge variant="outline">{item.orderType}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={getStatusBadgeVariant(item.status)}>{item.status}</Badge>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, item.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(item: Item, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={item.itemName}
subtitle={item.itemNumber}
badge={item.status}
badgeVariant={getStatusBadgeVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline'}
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(item)}
details={[
{ label: '품목유형', value: item.itemType },
{ label: '카테고리', value: item.categoryName },
{ label: '규격', value: item.specification },
{ label: '단위', value: item.unit },
{ label: '구분', value: item.orderType },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜선택 + 등록 버튼)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
}
/>
);
// 테이블 헤더 액션 (6개 필터)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 총 건수 */}
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedItems.length}
</span>
{/* 품목유형 필터 */}
<Select
value={itemTypeFilter}
onValueChange={(v) => {
setItemTypeFilter(v as ItemType | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="품목유형" />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 카테고리 필터 */}
<Select
value={categoryFilter}
onValueChange={(v) => {
setCategoryFilter(v);
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="카테고리" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{categoryOptions.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 규격 필터 */}
<Select
value={specificationFilter}
onValueChange={(v) => {
setSpecificationFilter(v as Specification | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder="규격" />
</SelectTrigger>
<SelectContent>
{SPECIFICATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 구분 필터 */}
<Select
value={orderTypeFilter}
onValueChange={(v) => {
setOrderTypeFilter(v as OrderType | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{ORDER_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 상태 필터 */}
<Select
value={statusFilter}
onValueChange={(v) => {
setStatusFilter(v as ItemStatus | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as 'latest' | 'oldest')}>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="품목관리"
description="품목을 등록하여 관리합니다."
icon={Package}
headerActions={headerActions}
stats={[
{
label: '전체 품목',
value: stats.total,
icon: Package,
iconColor: 'text-blue-500',
},
{
label: '사용 품목',
value: stats.active,
icon: PackageCheck,
iconColor: 'text-green-500',
},
]}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="품목명, 품목번호, 카테고리 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedItems}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedItems.length,
itemsPerPage: ITEMS_PER_PAGE,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,398 @@
'use server';
import type { Item, ItemStats, ItemListParams, ItemListResponse, ItemDetail, ItemFormData, OrderItem } from './types';
// 목데이터
const mockItems: Item[] = [
{
id: '1',
itemNumber: '123123',
itemType: '제품',
categoryId: '1',
categoryName: '카테고리명',
itemName: '품목명',
specification: '인정',
unit: 'SET',
orderType: '외주발주',
status: '승인',
createdAt: '2026-01-01T10:00:00Z',
updatedAt: '2026-01-01T10:00:00Z',
},
{
id: '2',
itemNumber: '123124',
itemType: '부품',
categoryId: '2',
categoryName: '모터',
itemName: '소형 모터 A',
specification: '비인정',
unit: 'SET',
orderType: '외주발주',
status: '승인',
createdAt: '2026-01-02T11:00:00Z',
updatedAt: '2026-01-02T11:00:00Z',
},
{
id: '3',
itemNumber: '123125',
itemType: '소모품',
categoryId: '3',
categoryName: '공정자재',
itemName: '절연테이프',
specification: '인정',
unit: 'SET',
orderType: '외주발주',
status: '승인',
createdAt: '2026-01-03T09:00:00Z',
updatedAt: '2026-01-03T09:00:00Z',
},
{
id: '4',
itemNumber: '123126',
itemType: '공과',
categoryId: '4',
categoryName: '철물',
itemName: '볼트 세트',
specification: '비인정',
unit: 'EA',
orderType: '경품발주',
status: '작업',
createdAt: '2026-01-03T10:00:00Z',
updatedAt: '2026-01-03T10:00:00Z',
},
{
id: '5',
itemNumber: '123127',
itemType: '부품',
categoryId: '1',
categoryName: '슬라이드 OPEN 사이즈',
itemName: '슬라이드 레일',
specification: '인정',
unit: 'EA',
orderType: '원자재발주',
status: '작업',
createdAt: '2026-01-04T08:00:00Z',
updatedAt: '2026-01-04T08:00:00Z',
},
{
id: '6',
itemNumber: '123128',
itemType: '소모품',
categoryId: '3',
categoryName: '공정자재',
itemName: '윤활유',
specification: '비인정',
unit: 'L',
orderType: '외주발주',
status: '사용',
createdAt: '2026-01-04T09:00:00Z',
updatedAt: '2026-01-04T09:00:00Z',
},
{
id: '7',
itemNumber: '123129',
itemType: '소모품',
categoryId: '3',
categoryName: '공정자재',
itemName: '포장재',
specification: '인정',
unit: 'BOX',
orderType: '경품발주',
status: '중지',
createdAt: '2026-01-05T10:00:00Z',
updatedAt: '2026-01-05T10:00:00Z',
},
];
// 품목 목록 조회
export async function getItemList(
params: ItemListParams = {}
): Promise<{ success: boolean; data?: ItemListResponse; error?: string }> {
try {
// 시뮬레이션 딜레이
await new Promise((resolve) => setTimeout(resolve, 300));
let filteredItems = [...mockItems];
// 물품유형 필터
if (params.itemType && params.itemType !== 'all') {
filteredItems = filteredItems.filter((item) => item.itemType === params.itemType);
}
// 카테고리 필터
if (params.categoryId && params.categoryId !== 'all') {
filteredItems = filteredItems.filter((item) => item.categoryId === params.categoryId);
}
// 규격 필터
if (params.specification && params.specification !== 'all') {
filteredItems = filteredItems.filter((item) => item.specification === params.specification);
}
// 구분 필터
if (params.orderType && params.orderType !== 'all') {
filteredItems = filteredItems.filter((item) => item.orderType === params.orderType);
}
// 상태 필터
if (params.status && params.status !== 'all') {
filteredItems = filteredItems.filter((item) => item.status === params.status);
}
// 검색어 필터
if (params.search) {
const search = params.search.toLowerCase();
filteredItems = filteredItems.filter(
(item) =>
item.itemNumber.toLowerCase().includes(search) ||
item.itemName.toLowerCase().includes(search) ||
item.categoryName.toLowerCase().includes(search)
);
}
// 날짜 필터
if (params.startDate) {
const startDate = new Date(params.startDate);
filteredItems = filteredItems.filter((item) => new Date(item.createdAt) >= startDate);
}
if (params.endDate) {
const endDate = new Date(params.endDate);
endDate.setHours(23, 59, 59, 999);
filteredItems = filteredItems.filter((item) => new Date(item.createdAt) <= endDate);
}
// 정렬
if (params.sortBy === 'oldest') {
filteredItems.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
} else {
// 기본: 최신순
filteredItems.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
// 페이지네이션
const page = params.page || 1;
const size = params.size || 20;
const start = (page - 1) * size;
const paginatedItems = filteredItems.slice(start, start + size);
return {
success: true,
data: {
items: paginatedItems,
total: filteredItems.length,
page,
size,
},
};
} catch (error) {
console.error('품목 목록 조회 오류:', error);
return { success: false, error: '품목 목록을 불러오는데 실패했습니다.' };
}
}
// 품목 통계 조회
export async function getItemStats(): Promise<{ success: boolean; data?: ItemStats; error?: string }> {
try {
await new Promise((resolve) => setTimeout(resolve, 100));
const total = mockItems.length;
const active = mockItems.filter((item) => item.status === '사용' || item.status === '승인').length;
return {
success: true,
data: { total, active },
};
} catch (error) {
console.error('품목 통계 조회 오류:', error);
return { success: false, error: '품목 통계를 불러오는데 실패했습니다.' };
}
}
// 품목 삭제
export async function deleteItem(id: string): Promise<{ success: boolean; error?: string }> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
// 실제 구현에서는 API 호출
const index = mockItems.findIndex((item) => item.id === id);
if (index !== -1) {
mockItems.splice(index, 1);
}
return { success: true };
} catch (error) {
console.error('품목 삭제 오류:', error);
return { success: false, error: '품목 삭제에 실패했습니다.' };
}
}
// 품목 일괄 삭제
export async function deleteItems(
ids: string[]
): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
let deletedCount = 0;
ids.forEach((id) => {
const index = mockItems.findIndex((item) => item.id === id);
if (index !== -1) {
mockItems.splice(index, 1);
deletedCount++;
}
});
return { success: true, deletedCount };
} catch (error) {
console.error('품목 일괄 삭제 오류:', error);
return { success: false, error: '품목 일괄 삭제에 실패했습니다.' };
}
}
// 카테고리 목록 조회 (필터용)
export async function getCategoryOptions(): Promise<{
success: boolean;
data?: { id: string; name: string }[];
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 100));
// 유니크한 카테고리 추출
const categories = [...new Map(mockItems.map((item) => [item.categoryId, { id: item.categoryId, name: item.categoryName }])).values()];
return { success: true, data: categories };
} catch (error) {
console.error('카테고리 목록 조회 오류:', error);
return { success: false, error: '카테고리 목록을 불러오는데 실패했습니다.' };
}
}
// 발주 항목 목데이터
const mockOrderItems: Record<string, OrderItem[]> = {
'1': [
{ id: 'oi1', label: '무게', value: '400KG' },
{ id: 'oi2', label: '무게', value: '500KG' },
],
'2': [
{ id: 'oi3', label: '전압', value: '220V' },
],
'3': [],
'4': [
{ id: 'oi4', label: '규격', value: 'M10x20' },
],
'5': [],
'6': [],
'7': [],
};
// 품목 상세 조회
export async function getItem(id: string): Promise<{
success: boolean;
data?: ItemDetail;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 200));
const item = mockItems.find((i) => i.id === id);
if (!item) {
return { success: false, error: '품목을 찾을 수 없습니다.' };
}
const itemDetail: ItemDetail = {
...item,
note: '',
orderItems: mockOrderItems[id] || [],
};
return { success: true, data: itemDetail };
} catch (error) {
console.error('품목 상세 조회 오류:', error);
return { success: false, error: '품목 정보를 불러오는데 실패했습니다.' };
}
}
// 품목 등록
export async function createItem(data: ItemFormData): Promise<{
success: boolean;
data?: { id: string };
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
// 새 ID 생성
const newId = String(Math.max(...mockItems.map((i) => parseInt(i.id))) + 1);
// 카테고리명 찾기
const category = mockItems.find((i) => i.categoryId === data.categoryId);
const categoryName = category?.categoryName || '기본';
const newItem: Item = {
id: newId,
itemNumber: data.itemNumber,
itemType: data.itemType,
categoryId: data.categoryId,
categoryName,
itemName: data.itemName,
specification: data.specification,
unit: data.unit,
orderType: data.orderType,
status: data.status,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockItems.push(newItem);
mockOrderItems[newId] = data.orderItems;
return { success: true, data: { id: newId } };
} catch (error) {
console.error('품목 등록 오류:', error);
return { success: false, error: '품목 등록에 실패했습니다.' };
}
}
// 품목 수정
export async function updateItem(
id: string,
data: ItemFormData
): Promise<{
success: boolean;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
const index = mockItems.findIndex((i) => i.id === id);
if (index === -1) {
return { success: false, error: '품목을 찾을 수 없습니다.' };
}
// 카테고리명 찾기
const category = mockItems.find((i) => i.categoryId === data.categoryId);
const categoryName = category?.categoryName || mockItems[index].categoryName;
mockItems[index] = {
...mockItems[index],
itemNumber: data.itemNumber,
itemType: data.itemType,
categoryId: data.categoryId,
categoryName,
itemName: data.itemName,
specification: data.specification,
unit: data.unit,
orderType: data.orderType,
status: data.status,
updatedAt: new Date().toISOString(),
};
mockOrderItems[id] = data.orderItems;
return { success: true };
} catch (error) {
console.error('품목 수정 오류:', error);
return { success: false, error: '품목 수정에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,46 @@
// 품목관리 상수 정의
import type { ItemType, Specification, OrderType, ItemStatus } from './types';
// 물품유형 옵션
export const ITEM_TYPE_OPTIONS: { value: ItemType | 'all'; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: '제품', label: '제품' },
{ value: '부품', label: '부품' },
{ value: '소모품', label: '소모품' },
{ value: '공과', label: '공과' },
];
// 규격 옵션
export const SPECIFICATION_OPTIONS: { value: Specification | 'all'; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: '인정', label: '인정' },
{ value: '비인정', label: '비인정' },
];
// 구분(발주유형) 옵션
export const ORDER_TYPE_OPTIONS: { value: OrderType | 'all'; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: '경품발주', label: '경품발주' },
{ value: '원자재발주', label: '원자재발주' },
{ value: '외주발주', label: '외주발주' },
];
// 상태 옵션
export const STATUS_OPTIONS: { value: ItemStatus | 'all'; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: '사용', label: '사용' },
{ value: '중지', label: '중지' },
];
// 정렬 옵션
export const SORT_OPTIONS: { value: 'latest' | 'oldest'; label: string }[] = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
];
// 단위 옵션
export const UNIT_OPTIONS = ['SET', 'EA', 'BOX', 'KG', 'M', 'L'];
// 페이지당 항목 수
export const ITEMS_PER_PAGE = 20;

View File

@@ -0,0 +1,5 @@
export { default as ItemManagementClient } from './ItemManagementClient';
export { default as ItemDetailClient } from './ItemDetailClient';
export * from './types';
export * from './constants';
export * from './actions';

View File

@@ -0,0 +1,85 @@
// 품목관리 타입 정의
// 물품유형
export type ItemType = '제품' | '부품' | '소모품' | '공과';
// 규격
export type Specification = '인정' | '비인정';
// 구분 (발주유형)
export type OrderType = '경품발주' | '원자재발주' | '외주발주';
// 상태
export type ItemStatus = '승인' | '작업' | '사용' | '중지';
// 품목 인터페이스
export interface Item {
id: string;
itemNumber: string; // 품목번호
itemType: ItemType; // 물품유형
categoryId: string; // 카테고리 ID
categoryName: string; // 카테고리명
itemName: string; // 품목명
specification: Specification; // 규격
unit: string; // 단위
orderType: OrderType; // 구분
status: ItemStatus; // 상태
createdAt: string;
updatedAt: string;
}
// 품목 통계
export interface ItemStats {
total: number; // 전체 품목
active: number; // 사용 품목
}
// 품목 목록 조회 파라미터
export interface ItemListParams {
page?: number;
size?: number;
itemType?: ItemType | 'all';
categoryId?: string;
specification?: Specification | 'all';
orderType?: OrderType | 'all';
status?: ItemStatus | 'all';
sortBy?: 'latest' | 'oldest';
search?: string;
startDate?: string;
endDate?: string;
}
// 품목 목록 응답
export interface ItemListResponse {
items: Item[];
total: number;
page: number;
size: number;
}
// 발주 항목
export interface OrderItem {
id: string;
label: string; // 예: 무게
value: string; // 예: 400KG
}
// 품목 상세 (발주 항목 포함)
export interface ItemDetail extends Item {
note?: string; // 비고
orderItems: OrderItem[]; // 발주 항목
}
// 품목 등록/수정 폼 데이터
export interface ItemFormData {
itemNumber: string;
itemType: ItemType;
categoryId: string;
itemName: string;
specification: Specification;
unit: string;
orderType: OrderType;
status: ItemStatus;
note?: string;
orderItems: OrderItem[];
}

View File

@@ -0,0 +1,417 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Hammer } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { Labor, LaborFormData, LaborCategory, LaborStatus } from './types';
import { CATEGORY_OPTIONS, STATUS_OPTIONS } from './constants';
import { getLabor, createLabor, updateLabor, deleteLabor } from './actions';
interface LaborDetailClientProps {
laborId?: string;
isEditMode?: boolean;
isNewMode?: boolean;
}
const initialFormData: LaborFormData = {
laborNumber: '',
category: '가로',
minM: 0,
maxM: 0,
laborPrice: null,
status: '사용',
};
export default function LaborDetailClient({
laborId,
isEditMode = false,
isNewMode = false,
}: LaborDetailClientProps) {
const router = useRouter();
// 모드 상태
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
isNewMode ? 'new' : isEditMode ? 'edit' : 'view'
);
// 폼 데이터
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
const [originalData, setOriginalData] = useState<Labor | null>(null);
// 상태
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// 노임 데이터 로드
useEffect(() => {
if (laborId && !isNewMode) {
const loadLabor = async () => {
setIsLoading(true);
try {
const result = await getLabor(laborId);
if (result.success && result.data) {
setOriginalData(result.data);
setFormData({
laborNumber: result.data.laborNumber,
category: result.data.category,
minM: result.data.minM,
maxM: result.data.maxM,
laborPrice: result.data.laborPrice,
status: result.data.status,
});
} else {
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/juil/order/base-info/labor');
}
} catch {
toast.error('노임 정보를 불러오는데 실패했습니다.');
router.push('/ko/juil/order/base-info/labor');
} finally {
setIsLoading(false);
}
};
loadLabor();
}
}, [laborId, isNewMode, router]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof LaborFormData, value: string | number | null) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// 숫자 입력 (소수점 둘째자리까지)
const handleNumberChange = useCallback(
(field: 'minM' | 'maxM' | 'laborPrice', value: string) => {
if (value === '') {
handleFieldChange(field, field === 'laborPrice' ? null : 0);
return;
}
// 소수점 둘째자리까지 허용
const regex = /^\d*\.?\d{0,2}$/;
if (regex.test(value)) {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
handleFieldChange(field, numValue);
}
}
},
[handleFieldChange]
);
// 저장
const handleSave = useCallback(async () => {
// 유효성 검사
if (!formData.laborNumber.trim()) {
toast.error('노임번호를 입력해주세요.');
return;
}
setIsSaving(true);
try {
if (mode === 'new') {
const result = await createLabor(formData);
if (result.success && result.data) {
toast.success('노임이 등록되었습니다.');
router.push(`/ko/juil/order/base-info/labor/${result.data.id}`);
} else {
toast.error(result.error || '노임 등록에 실패했습니다.');
}
} else if (mode === 'edit' && laborId) {
const result = await updateLabor(laborId, formData);
if (result.success) {
toast.success('노임이 수정되었습니다.');
setMode('view');
// 데이터 다시 로드
const reloadResult = await getLabor(laborId);
if (reloadResult.success && reloadResult.data) {
setOriginalData(reloadResult.data);
}
} else {
toast.error(result.error || '노임 수정에 실패했습니다.');
}
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [mode, formData, laborId, router]);
// 삭제
const handleDelete = useCallback(async () => {
if (!laborId) return;
setIsLoading(true);
try {
const result = await deleteLabor(laborId);
if (result.success) {
toast.success('노임이 삭제되었습니다.');
router.push('/ko/juil/order/base-info/labor');
} else {
toast.error(result.error || '노임 삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
}
}, [laborId, router]);
// 수정 모드 전환
const handleEditMode = useCallback(() => {
setMode('edit');
router.replace(`/ko/juil/order/base-info/labor/${laborId}?mode=edit`);
}, [laborId, router]);
// 목록으로 이동
const handleBack = useCallback(() => {
router.push('/ko/juil/order/base-info/labor');
}, [router]);
// 취소
const handleCancel = useCallback(() => {
if (mode === 'new') {
router.push('/ko/juil/order/base-info/labor');
} else {
setMode('view');
// 원본 데이터로 복원
if (originalData) {
setFormData({
laborNumber: originalData.laborNumber,
category: originalData.category,
minM: originalData.minM,
maxM: originalData.maxM,
laborPrice: originalData.laborPrice,
status: originalData.status,
});
}
router.replace(`/ko/juil/order/base-info/labor/${laborId}`);
}
}, [mode, laborId, originalData, router]);
// 읽기 전용 여부
const isReadOnly = mode === 'view';
// 페이지 타이틀
const pageTitle = mode === 'new' ? '노임 등록' : '노임 상세';
// 액션 버튼
const actionButtons = (
<div className="flex items-center gap-2">
{mode === 'view' && (
<>
<Button variant="outline" onClick={handleBack}>
</Button>
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(true)}
>
</Button>
<Button onClick={handleEditMode}></Button>
</>
)}
{mode === 'edit' && (
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? '저장 중...' : '저장'}
</Button>
</>
)}
{mode === 'new' && (
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? '등록 중...' : '등록'}
</Button>
</>
)}
</div>
);
if (isLoading && !isNewMode) {
return (
<PageLayout>
<PageHeader
title={pageTitle}
description="노임 정보를 등록하고 관리합니다."
icon={Hammer}
/>
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground"> ...</div>
</div>
</PageLayout>
);
}
return (
<>
<PageLayout>
<PageHeader
title={pageTitle}
description="노임 정보를 등록하고 관리합니다."
icon={Hammer}
actions={actionButtons}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base">
<span className="text-destructive">*</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Row 1: 노임번호, 구분 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="laborNumber"></Label>
<Input
id="laborNumber"
value={formData.laborNumber}
onChange={(e) => handleFieldChange('laborNumber', e.target.value)}
placeholder="노임번호를 입력하세요"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category"></Label>
<Select
value={formData.category}
onValueChange={(v) => handleFieldChange('category', v as LaborCategory)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="구분 선택" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 2: 최소 M, 최대 M */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="minM"> M</Label>
<Input
id="minM"
type="text"
inputMode="decimal"
value={formData.minM === 0 ? '' : formData.minM.toString()}
onChange={(e) => handleNumberChange('minM', e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxM"> M</Label>
<Input
id="maxM"
type="text"
inputMode="decimal"
value={formData.maxM === 0 ? '' : formData.maxM.toString()}
onChange={(e) => handleNumberChange('maxM', e.target.value)}
placeholder="0.00"
disabled={isReadOnly}
/>
</div>
</div>
{/* Row 3: 노임단가, 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="laborPrice"></Label>
<Input
id="laborPrice"
type="text"
inputMode="decimal"
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
onChange={(e) => handleNumberChange('laborPrice', e.target.value)}
placeholder="0"
disabled={isReadOnly}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(v) => handleFieldChange('status', v as LaborStatus)}
disabled={isReadOnly}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,529 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format, startOfYear, endOfYear } from 'date-fns';
import { Hammer, Plus, Pencil, Trash2, HardHat } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedListTemplateV2, TableColumn } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Labor, LaborStats, LaborCategory, LaborStatus, SortOrder } from './types';
import { CATEGORY_OPTIONS, STATUS_OPTIONS, SORT_OPTIONS, DEFAULT_PAGE_SIZE } from './constants';
import { getLaborList, deleteLabor, deleteLaborBulk, getLaborStats } from './actions';
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'laborNumber', label: '노임번호', className: 'w-[120px]' },
{ key: 'category', label: '구분', className: 'w-[100px] text-center' },
{ key: 'minM', label: '최소 M', className: 'w-[100px] text-right' },
{ key: 'maxM', label: '최대 M', className: 'w-[100px] text-right' },
{ key: 'laborPrice', label: '노임단가', className: 'w-[120px] text-right' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
];
interface LaborManagementClientProps {
initialData?: Labor[];
initialStats?: LaborStats;
}
export default function LaborManagementClient({
initialData = [],
initialStats,
}: LaborManagementClientProps) {
const router = useRouter();
const today = new Date();
// 날짜 상태 (당해년도 기본값)
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(format(endOfYear(today), 'yyyy-MM-dd'));
// 상태
const [labors, setLabors] = useState<Labor[]>(initialData);
const [stats, setStats] = useState<LaborStats>(initialStats ?? { total: 0, active: 0 });
const [searchValue, setSearchValue] = useState('');
const [categoryFilter, setCategoryFilter] = useState<LaborCategory | 'all'>('all');
const [statusFilter, setStatusFilter] = useState<LaborStatus | 'all'>('all');
const [sortBy, setSortBy] = useState<SortOrder>('최신순');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getLaborList({
category: categoryFilter,
status: statusFilter,
sortOrder: sortBy,
startDate,
endDate,
}),
getLaborStats(),
]);
if (listResult.success && listResult.data) {
setLabors(listResult.data);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [categoryFilter, statusFilter, sortBy, startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredLabors = useMemo(() => {
return labors.filter((labor) => {
// 구분 필터
if (categoryFilter !== 'all' && labor.category !== categoryFilter) {
return false;
}
// 상태 필터
if (statusFilter !== 'all' && labor.status !== statusFilter) {
return false;
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
labor.laborNumber.toLowerCase().includes(search) ||
labor.category.toLowerCase().includes(search)
);
}
return true;
});
}, [labors, categoryFilter, statusFilter, searchValue]);
// 정렬
const sortedLabors = useMemo(() => {
const sorted = [...filteredLabors];
if (sortBy === '등록순') {
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
} else {
// 기본: 최신순
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
return sorted;
}, [filteredLabors, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedLabors.length / DEFAULT_PAGE_SIZE);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * DEFAULT_PAGE_SIZE;
return sortedLabors.slice(start, start + DEFAULT_PAGE_SIZE);
}, [sortedLabors, currentPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((labor) => labor.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(labor: Labor) => {
router.push(`/ko/juil/order/base-info/labor/${labor.id}`);
},
[router]
);
const handleCreate = useCallback(() => {
router.push('/ko/juil/order/base-info/labor/new');
}, [router]);
const handleEdit = useCallback(
(e: React.MouseEvent, laborId: string) => {
e.stopPropagation();
router.push(`/ko/juil/order/base-info/labor/${laborId}?mode=edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, laborId: string) => {
e.stopPropagation();
setDeleteTargetId(laborId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteLabor(deleteTargetId);
if (result.success) {
toast.success('노임이 삭제되었습니다.');
setLabors((prev) => prev.filter((labor) => labor.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
// 통계 재조회
const statsResult = await getLaborStats();
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteLaborBulk(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
// 상태 배지 색상
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case '사용':
return 'default';
case '중지':
return 'destructive';
default:
return 'outline';
}
};
// 가격 포맷
const formatPrice = (price: number | null) => {
if (price === null || price === 0) return '-';
return price.toLocaleString();
};
// M 값 포맷
const formatM = (value: number) => {
if (value === 0) return '-';
return value.toFixed(2);
};
// 테이블 행 렌더링
const renderTableRow = useCallback(
(labor: Labor, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(labor.id);
return (
<TableRow
key={labor.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(labor)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(labor.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{labor.laborNumber}</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{labor.category}</Badge>
</TableCell>
<TableCell className="text-right">{formatM(labor.minM)}</TableCell>
<TableCell className="text-right">{formatM(labor.maxM)}</TableCell>
<TableCell className="text-right font-medium">{formatPrice(labor.laborPrice)}</TableCell>
<TableCell className="text-center">
<Badge variant={getStatusBadgeVariant(labor.status)}>{labor.status}</Badge>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, labor.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, labor.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(labor: Labor, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={labor.laborNumber}
subtitle={labor.category}
badge={labor.status}
badgeVariant={getStatusBadgeVariant(labor.status) as 'default' | 'secondary' | 'destructive' | 'outline'}
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(labor)}
details={[
{ label: '최소 M', value: formatM(labor.minM) },
{ label: '최대 M', value: formatM(labor.maxM) },
{ label: '노임단가', value: formatPrice(labor.laborPrice) },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜선택 + 등록 버튼)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
}
/>
);
// 테이블 헤더 액션 (필터)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 총 건수 */}
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedLabors.length}
</span>
{/* 구분 필터 */}
<Select
value={categoryFilter}
onValueChange={(v) => {
setCategoryFilter(v as LaborCategory | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 상태 필터 */}
<Select
value={statusFilter}
onValueChange={(v) => {
setStatusFilter(v as LaborStatus | 'all');
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[80px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOrder)}>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="노임관리"
description="노임을 등록하고 관리합니다."
icon={Hammer}
headerActions={headerActions}
stats={[
{
label: '전체 노임',
value: stats.total,
icon: Hammer,
iconColor: 'text-blue-500',
},
{
label: '사용 노임',
value: stats.active,
icon: HardHat,
iconColor: 'text-green-500',
},
]}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="노임번호, 구분 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedLabors}
getItemId={(labor) => labor.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedLabors.length,
itemsPerPage: DEFAULT_PAGE_SIZE,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,276 @@
'use server';
import type { Labor, LaborListParams, LaborFormData, LaborStats } from './types';
// 목데이터 - 7건
const mockLabors: Labor[] = [
{
id: '1',
laborNumber: '123123',
category: '가로',
minM: 0,
maxM: 6.00,
laborPrice: 400000,
status: '사용',
createdAt: '2026-01-03T10:00:00Z',
updatedAt: '2026-01-03T10:00:00Z',
},
{
id: '2',
laborNumber: '123123',
category: '세로할증',
minM: 3.50,
maxM: 3.00,
laborPrice: null,
status: '중지',
createdAt: '2026-01-03T09:00:00Z',
updatedAt: '2026-01-03T09:00:00Z',
},
{
id: '3',
laborNumber: '123123',
category: '가로',
minM: 6.01,
maxM: 7.00,
laborPrice: null,
status: '사용',
createdAt: '2026-01-02T15:00:00Z',
updatedAt: '2026-01-02T15:00:00Z',
},
{
id: '4',
laborNumber: '123123',
category: '세로할증',
minM: 3.51,
maxM: 4.50,
laborPrice: 50000,
status: '사용',
createdAt: '2026-01-02T14:00:00Z',
updatedAt: '2026-01-02T14:00:00Z',
},
{
id: '5',
laborNumber: '123123',
category: '가로',
minM: 0,
maxM: 6.00,
laborPrice: null,
status: '사용',
createdAt: '2026-01-01T12:00:00Z',
updatedAt: '2026-01-01T12:00:00Z',
},
{
id: '6',
laborNumber: '123123',
category: '세로할증',
minM: 3.50,
maxM: 0,
laborPrice: 50000,
status: '사용',
createdAt: '2026-01-01T11:00:00Z',
updatedAt: '2026-01-01T11:00:00Z',
},
{
id: '7',
laborNumber: '123123',
category: '가로',
minM: 0,
maxM: 0,
laborPrice: null,
status: '중지',
createdAt: '2026-01-01T10:00:00Z',
updatedAt: '2026-01-01T10:00:00Z',
},
];
// 노임 목록 조회
export async function getLaborList(params: LaborListParams = {}): Promise<{
success: boolean;
data?: Labor[];
total?: number;
error?: string;
}> {
try {
let filtered = [...mockLabors];
// 검색어 필터
if (params.search) {
const searchLower = params.search.toLowerCase();
filtered = filtered.filter(
(labor) =>
labor.laborNumber.toLowerCase().includes(searchLower) ||
labor.category.toLowerCase().includes(searchLower)
);
}
// 구분 필터
if (params.category && params.category !== 'all') {
filtered = filtered.filter((labor) => labor.category === params.category);
}
// 상태 필터
if (params.status && params.status !== 'all') {
filtered = filtered.filter((labor) => labor.status === params.status);
}
// 날짜 필터
if (params.startDate) {
filtered = filtered.filter(
(labor) => new Date(labor.createdAt) >= new Date(params.startDate!)
);
}
if (params.endDate) {
const endDate = new Date(params.endDate);
endDate.setHours(23, 59, 59, 999);
filtered = filtered.filter(
(labor) => new Date(labor.createdAt) <= endDate
);
}
// 정렬
if (params.sortOrder === '등록순') {
filtered.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
} else {
// 기본: 최신순
filtered.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
const total = filtered.length;
// 페이지네이션
if (params.page && params.limit) {
const start = (params.page - 1) * params.limit;
filtered = filtered.slice(start, start + params.limit);
}
return { success: true, data: filtered, total };
} catch (error) {
console.error('노임 목록 조회 실패:', error);
return { success: false, error: '노임 목록을 불러오는데 실패했습니다.' };
}
}
// 노임 통계 조회
export async function getLaborStats(): Promise<{
success: boolean;
data?: LaborStats;
error?: string;
}> {
try {
const total = mockLabors.length;
const active = mockLabors.filter((labor) => labor.status === '사용').length;
return { success: true, data: { total, active } };
} catch (error) {
console.error('노임 통계 조회 실패:', error);
return { success: false, error: '노임 통계를 불러오는데 실패했습니다.' };
}
}
// 노임 상세 조회
export async function getLabor(id: string): Promise<{
success: boolean;
data?: Labor;
error?: string;
}> {
try {
const labor = mockLabors.find((l) => l.id === id);
if (!labor) {
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
}
return { success: true, data: labor };
} catch (error) {
console.error('노임 상세 조회 실패:', error);
return { success: false, error: '노임 정보를 불러오는데 실패했습니다.' };
}
}
// 노임 등록
export async function createLabor(data: LaborFormData): Promise<{
success: boolean;
data?: Labor;
error?: string;
}> {
try {
const newLabor: Labor = {
id: String(Date.now()),
...data,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockLabors.unshift(newLabor);
return { success: true, data: newLabor };
} catch (error) {
console.error('노임 등록 실패:', error);
return { success: false, error: '노임 등록에 실패했습니다.' };
}
}
// 노임 수정
export async function updateLabor(
id: string,
data: LaborFormData
): Promise<{
success: boolean;
data?: Labor;
error?: string;
}> {
try {
const index = mockLabors.findIndex((l) => l.id === id);
if (index === -1) {
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
}
mockLabors[index] = {
...mockLabors[index],
...data,
updatedAt: new Date().toISOString(),
};
return { success: true, data: mockLabors[index] };
} catch (error) {
console.error('노임 수정 실패:', error);
return { success: false, error: '노임 수정에 실패했습니다.' };
}
}
// 노임 삭제
export async function deleteLabor(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
const index = mockLabors.findIndex((l) => l.id === id);
if (index === -1) {
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
}
mockLabors.splice(index, 1);
return { success: true };
} catch (error) {
console.error('노임 삭제 실패:', error);
return { success: false, error: '노임 삭제에 실패했습니다.' };
}
}
// 노임 일괄 삭제
export async function deleteLaborBulk(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
try {
let deletedCount = 0;
for (const id of ids) {
const index = mockLabors.findIndex((l) => l.id === id);
if (index !== -1) {
mockLabors.splice(index, 1);
deletedCount++;
}
}
return { success: true, deletedCount };
} catch (error) {
console.error('노임 일괄 삭제 실패:', error);
return { success: false, error: '노임 일괄 삭제에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,24 @@
// 노임관리 상수 정의
// 구분 옵션
export const CATEGORY_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '가로', label: '가로' },
{ value: '세로할증', label: '세로할증' },
] as const;
// 상태 옵션
export const STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '사용', label: '사용' },
{ value: '중지', label: '중지' },
] as const;
// 정렬 옵션
export const SORT_OPTIONS = [
{ value: '최신순', label: '최신순' },
{ value: '등록순', label: '등록순' },
] as const;
// 기본 페이지 사이즈
export const DEFAULT_PAGE_SIZE = 20;

View File

@@ -0,0 +1,5 @@
export { default as LaborManagementClient } from './LaborManagementClient';
export { default as LaborDetailClient } from './LaborDetailClient';
export * from './types';
export * from './constants';
export * from './actions';

View File

@@ -0,0 +1,51 @@
// 노임관리 타입 정의
// 구분 타입
export type LaborCategory = '가로' | '세로할증';
// 상태 타입
export type LaborStatus = '사용' | '중지';
// 정렬 타입
export type SortOrder = '최신순' | '등록순';
// 노임 데이터 타입
export interface Labor {
id: string;
laborNumber: string;
category: LaborCategory;
minM: number;
maxM: number;
laborPrice: number | null;
status: LaborStatus;
createdAt: string;
updatedAt: string;
}
// 노임 목록 조회 파라미터
export interface LaborListParams {
startDate?: string;
endDate?: string;
search?: string;
category?: LaborCategory | 'all';
status?: LaborStatus | 'all';
sortOrder?: SortOrder;
page?: number;
limit?: number;
}
// 노임 등록/수정 폼 데이터
export interface LaborFormData {
laborNumber: string;
category: LaborCategory;
minM: number;
maxM: number;
laborPrice: number | null;
status: LaborStatus;
}
// 노임 통계
export interface LaborStats {
total: number;
active: number;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,845 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Package, Clock, CheckCircle, XCircle, Pencil, Trash2, AlertCircle, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { format, parseISO, isSameDay, startOfDay } from 'date-fns';
import type { Order, OrderStats, OrderType } from './types';
import {
ORDER_STATUS_OPTIONS,
ORDER_SORT_OPTIONS,
ORDER_STATUS_STYLES,
ORDER_STATUS_LABELS,
ORDER_STATUS_CALENDAR_COLORS,
ORDER_TYPE_OPTIONS,
ORDER_TYPE_LABELS,
MOCK_PARTNERS,
MOCK_SITES,
MOCK_CONSTRUCTION_PM,
MOCK_ORDER_MANAGERS,
MOCK_ORDER_COMPANIES,
MOCK_WORK_TEAM_LEADERS,
} from './types';
import {
getOrderList,
getOrderStats,
deleteOrder,
deleteOrders,
} from './actions';
// 테이블 컬럼 정의
// 체크박스, 번호, 계약번호, 거래처, 현장명, 명칭, 공사PM, 발주담당자, 발주번호, 발주처명, 작업반장, 시공투입일, 구분, 품목, 수량, 발주일, 계획납품일, 실제 납품일, 상태, 작업
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'contractNumber', label: '계약번호', className: 'w-[100px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[80px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
{ key: 'name', label: '명칭', className: 'w-[80px]' },
{ key: 'constructionPM', label: '공사PM', className: 'w-[70px]' },
{ key: 'orderManager', label: '발주담당자', className: 'w-[80px]' },
{ key: 'orderNumber', label: '발주번호', className: 'w-[100px]' },
{ key: 'orderCompany', label: '발주처명', className: 'w-[80px]' },
{ key: 'workTeamLeader', label: '작업반장', className: 'w-[70px]' },
{ key: 'constructionStartDate', label: '시공투입일', className: 'w-[90px]' },
{ key: 'orderType', label: '구분', className: 'w-[80px] text-center' },
{ key: 'item', label: '품목', className: 'w-[80px]' },
{ key: 'quantity', label: '수량', className: 'w-[60px] text-right' },
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' },
{ key: 'plannedDeliveryDate', label: '계획납품일', className: 'w-[90px]' },
{ key: 'actualDeliveryDate', label: '실제납품일', className: 'w-[90px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
interface OrderManagementListClientProps {
initialData?: Order[];
initialStats?: OrderStats;
}
export default function OrderManagementListClient({
initialData = [],
initialStats,
}: OrderManagementListClientProps) {
const router = useRouter();
// 상태
const [orders, setOrders] = useState<Order[]>(initialData);
const [stats, setStats] = useState<OrderStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
// 다중선택 필터 (빈 배열 = 전체)
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [siteNameFilters, setSiteNameFilters] = useState<string[]>([]);
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
const [orderManagerFilters, setOrderManagerFilters] = useState<string[]>([]);
const [orderCompanyFilters, setOrderCompanyFilters] = useState<string[]>([]);
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
const [orderTypeFilters, setOrderTypeFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
// 달력용 필터
const [siteFilters, setSiteFilters] = useState<string[]>([]);
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'order_complete' | 'delivery_scheduled' | 'delivery_complete'>('all');
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getOrderList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getOrderStats(),
]);
if (listResult.success && listResult.data) {
setOrders(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 다중선택 필터 옵션 변환 (MultiSelectCombobox용)
const siteOptions: MultiSelectOption[] = useMemo(() => MOCK_SITES, []);
const workTeamOptions: MultiSelectOption[] = useMemo(() => MOCK_WORK_TEAM_LEADERS, []);
// 달력용 이벤트 데이터 변환 (필터 적용)
const calendarEvents: ScheduleEvent[] = useMemo(() => {
return orders
.filter((order) => {
// 현장 필터 (빈 배열 = 전체)
// 목업이므로 siteName으로 매칭 (실제로는 siteId로 매칭해야 함)
if (siteFilters.length > 0) {
const matchingSite = MOCK_SITES.find((s) => order.siteName.includes(s.label.split(' ')[0]));
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
return false;
}
}
// 작업반장 필터 (빈 배열 = 전체)
// 목업이므로 orderManager로 매칭 (실제로는 workTeamLeaderId로 매칭해야 함)
if (workTeamFilters.length > 0) {
const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => order.orderManager.includes(l.label.replace('반장', '')));
if (!matchingLeader || !workTeamFilters.includes(matchingLeader.value)) {
return false;
}
}
return true;
})
.map((order) => ({
id: order.id,
title: `${order.orderManager} - ${order.siteName} / ${order.orderNumber}`,
startDate: order.periodStart,
endDate: order.periodEnd,
color: ORDER_STATUS_CALENDAR_COLORS[order.status],
status: order.status,
data: order,
}));
}, [orders, siteFilters, workTeamFilters]);
// 달력용 뱃지 데이터 - 사용하지 않음 (기획서 Description 번호는 설명용이므로 UI에 구현 안함)
const calendarBadges: DayBadge[] = [];
// 필터링된 데이터
const filteredOrders = useMemo(() => {
return orders.filter((order) => {
// 상태 탭 필터
if (activeStatTab === 'waiting' && order.status !== 'waiting') return false;
if (activeStatTab === 'order_complete' && order.status !== 'order_complete') return false;
if (activeStatTab === 'delivery_scheduled' && order.status !== 'delivery_scheduled') return false;
if (activeStatTab === 'delivery_complete' && order.status !== 'delivery_complete') return false;
// 상태 필터
if (statusFilter !== 'all' && order.status !== statusFilter) return false;
// 거래처 필터 (다중선택)
if (partnerFilters.length > 0) {
const matchingPartner = MOCK_PARTNERS.find((p) => p.label === order.partnerName);
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
return false;
}
}
// 현장명 필터 (다중선택)
if (siteNameFilters.length > 0) {
const matchingSite = MOCK_SITES.find((s) => s.label === order.siteName);
if (!matchingSite || !siteNameFilters.includes(matchingSite.value)) {
return false;
}
}
// 공사PM 필터 (다중선택)
if (constructionPMFilters.length > 0) {
const matchingPM = MOCK_CONSTRUCTION_PM.find((p) => p.label === order.constructionPM);
if (!matchingPM || !constructionPMFilters.includes(matchingPM.value)) {
return false;
}
}
// 발주담당자 필터 (다중선택)
if (orderManagerFilters.length > 0) {
const matchingManager = MOCK_ORDER_MANAGERS.find((m) => m.label === order.orderManager);
if (!matchingManager || !orderManagerFilters.includes(matchingManager.value)) {
return false;
}
}
// 발주처 필터 (다중선택)
if (orderCompanyFilters.length > 0) {
const matchingCompany = MOCK_ORDER_COMPANIES.find((c) => c.label === order.orderCompany);
if (!matchingCompany || !orderCompanyFilters.includes(matchingCompany.value)) {
return false;
}
}
// 작업반장 필터 (다중선택) - 테이블용
if (workTeamFilters.length > 0) {
const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => l.label === order.workTeamLeader);
if (!matchingLeader || !workTeamFilters.includes(matchingLeader.value)) {
return false;
}
}
// 구분 필터 (다중선택)
if (orderTypeFilters.length > 0 && !orderTypeFilters.includes(order.orderType)) {
return false;
}
// 달력 날짜 필터 (시간 무시, 날짜만 비교)
if (selectedCalendarDate) {
const orderStart = startOfDay(parseISO(order.periodStart));
const orderEnd = startOfDay(parseISO(order.periodEnd));
const selected = startOfDay(selectedCalendarDate);
// 선택된 날짜가 기간 내에 있는지 확인
if (selected < orderStart || selected > orderEnd) {
return false;
}
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
order.orderNumber.toLowerCase().includes(search) ||
order.partnerName.toLowerCase().includes(search) ||
order.siteName.toLowerCase().includes(search) ||
order.orderManager.toLowerCase().includes(search)
);
}
return true;
});
}, [orders, activeStatTab, statusFilter, partnerFilters, siteNameFilters, constructionPMFilters, orderManagerFilters, orderCompanyFilters, workTeamFilters, orderTypeFilters, selectedCalendarDate, searchValue]);
// 정렬
const sortedOrders = useMemo(() => {
const sorted = [...filteredOrders];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'siteNameAsc':
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
break;
case 'siteNameDesc':
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
break;
case 'deliveryDateAsc':
sorted.sort((a, b) => a.plannedDeliveryDate.localeCompare(b.plannedDeliveryDate));
break;
case 'deliveryDateDesc':
sorted.sort((a, b) => b.plannedDeliveryDate.localeCompare(a.plannedDeliveryDate));
break;
}
return sorted;
}, [filteredOrders, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedOrders.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedOrders.slice(start, start + itemsPerPage);
}, [sortedOrders, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((o) => o.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(order: Order) => {
router.push(`/ko/juil/order/order-management/${order.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, orderId: string) => {
e.stopPropagation();
router.push(`/ko/juil/order/order-management/${orderId}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, orderId: string) => {
e.stopPropagation();
setDeleteTargetId(orderId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteOrder(deleteTargetId);
if (result.success) {
toast.success('발주가 삭제되었습니다.');
setOrders((prev) => prev.filter((o) => o.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteOrders(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
const handleRegister = useCallback(() => {
router.push('/ko/juil/order/order-management/new');
}, [router]);
// 달력 이벤트 핸들러
const handleCalendarDateClick = useCallback((date: Date) => {
// 같은 날짜 클릭 시 선택 해제
if (selectedCalendarDate && isSameDay(selectedCalendarDate, date)) {
setSelectedCalendarDate(null);
} else {
setSelectedCalendarDate(date);
}
setCurrentPage(1);
}, [selectedCalendarDate]);
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
if (event.data) {
router.push(`/ko/juil/order/order-management/${event.id}`);
}
}, [router]);
const handleCalendarMonthChange = useCallback((date: Date) => {
setCalendarDate(date);
}, []);
// 날짜 포맷
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
};
// 테이블 행 렌더링
// 체크박스, 번호, 계약번호, 거래처, 현장명, 명칭, 공사PM, 발주담당자, 발주번호, 발주처명, 작업반장, 시공투입일, 구분, 품목, 수량, 발주일, 계획납품일, 실제 납품일, 상태, 작업
const renderTableRow = useCallback(
(order: Order, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(order.id);
return (
<TableRow
key={order.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(order)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(order.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{order.contractNumber}</TableCell>
<TableCell>{order.partnerName}</TableCell>
<TableCell>{order.siteName}</TableCell>
<TableCell>{order.name}</TableCell>
<TableCell>{order.constructionPM}</TableCell>
<TableCell>{order.orderManager}</TableCell>
<TableCell>{order.orderNumber}</TableCell>
<TableCell>{order.orderCompany}</TableCell>
<TableCell>{order.workTeamLeader}</TableCell>
<TableCell>{formatDate(order.constructionStartDate)}</TableCell>
<TableCell className="text-center">
<span className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{ORDER_TYPE_LABELS[order.orderType]}
</span>
</TableCell>
<TableCell>{order.item}</TableCell>
<TableCell className="text-right">{order.quantity}</TableCell>
<TableCell>{formatDate(order.orderDate)}</TableCell>
<TableCell>{formatDate(order.plannedDeliveryDate)}</TableCell>
<TableCell>{formatDate(order.actualDeliveryDate)}</TableCell>
<TableCell className="text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ORDER_STATUS_STYLES[order.status]}`}>
{ORDER_STATUS_LABELS[order.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, order.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, order.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(order: Order, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={order.siteName}
subtitle={order.orderNumber}
badge={ORDER_STATUS_LABELS[order.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(order)}
details={[
{ label: '거래처', value: order.partnerName },
{ label: '발주담당', value: order.orderManager },
{ label: '계획납품일', value: formatDate(order.plannedDeliveryDate) },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (노임관리와 동일한 패턴 - 달력과 등록 버튼 같은 줄)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
<Button onClick={handleRegister}>
<Plus className="mr-2 h-4 w-4" />
</Button>
}
/>
);
// Stats 카드 데이터
const statsCardsData: StatCard[] = [
{
label: '전체 발주',
value: stats?.total ?? 0,
icon: Package,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '발주대기',
value: stats?.waiting ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => setActiveStatTab('waiting'),
isActive: activeStatTab === 'waiting',
},
{
label: '발주완료',
value: stats?.orderComplete ?? 0,
icon: AlertCircle,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('order_complete'),
isActive: activeStatTab === 'order_complete',
},
{
label: '납품완료',
value: stats?.deliveryComplete ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('delivery_complete'),
isActive: activeStatTab === 'delivery_complete',
},
];
// 필터 옵션들
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
const constructionPMOptions: MultiSelectOption[] = useMemo(() => MOCK_CONSTRUCTION_PM, []);
const orderManagerOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_MANAGERS, []);
const orderCompanyOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_COMPANIES, []);
const orderTypeOptions: MultiSelectOption[] = useMemo(() => ORDER_TYPE_OPTIONS, []);
// 테이블 헤더 액션 (기획서 요구사항)
// 거래처, 현장명, 공사PM, 발주담당자, 발주처, 작업반장, 구분, 상태, 최신순
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 총건 표시 */}
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedOrders.length}
{selectedCalendarDate && (
<span className="ml-2 text-primary">
({format(selectedCalendarDate, 'M/d')} )
</span>
)}
</span>
{/* 1. 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={partnerOptions}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 2. 현장명 필터 (다중선택) */}
<MultiSelectCombobox
options={siteOptions}
value={siteNameFilters}
onChange={setSiteNameFilters}
placeholder="현장명"
searchPlaceholder="현장명 검색..."
className="w-[140px]"
/>
{/* 3. 공사PM 필터 (다중선택) */}
<MultiSelectCombobox
options={constructionPMOptions}
value={constructionPMFilters}
onChange={setConstructionPMFilters}
placeholder="공사PM"
searchPlaceholder="공사PM 검색..."
className="w-[120px]"
/>
{/* 4. 발주담당자 필터 (다중선택) */}
<MultiSelectCombobox
options={orderManagerOptions}
value={orderManagerFilters}
onChange={setOrderManagerFilters}
placeholder="발주담당자"
searchPlaceholder="발주담당자 검색..."
className="w-[120px]"
/>
{/* 5. 발주처 필터 (다중선택) */}
<MultiSelectCombobox
options={orderCompanyOptions}
value={orderCompanyFilters}
onChange={setOrderCompanyFilters}
placeholder="발주처"
searchPlaceholder="발주처 검색..."
className="w-[100px]"
/>
{/* 6. 작업반장 필터 (다중선택) */}
<MultiSelectCombobox
options={workTeamOptions}
value={workTeamFilters}
onChange={setWorkTeamFilters}
placeholder="작업반장"
searchPlaceholder="작업반장 검색..."
className="w-[110px]"
/>
{/* 7. 구분 필터 (다중선택) */}
<MultiSelectCombobox
options={orderTypeOptions}
value={orderTypeFilters}
onChange={setOrderTypeFilters}
placeholder="구분"
searchPlaceholder="구분 검색..."
className="w-[100px]"
/>
{/* 8. 상태 필터 (단일선택) */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{ORDER_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 9. 최신순 필터 (단일선택) */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{ORDER_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 달력 날짜 필터 초기화 */}
{selectedCalendarDate && (
<Button
variant="outline"
size="sm"
onClick={() => setSelectedCalendarDate(null)}
>
</Button>
)}
</div>
);
// 달력 필터 슬롯 (현장 + 작업반장 - 다중선택)
const calendarFilterSlot = (
<div className="flex items-center gap-2">
{/* 현장 필터 (다중선택) */}
<MultiSelectCombobox
options={siteOptions}
value={siteFilters}
onChange={setSiteFilters}
placeholder="현장"
searchPlaceholder="현장 검색..."
className="w-[160px]"
/>
{/* 작업반장 필터 (다중선택) */}
<MultiSelectCombobox
options={workTeamOptions}
value={workTeamFilters}
onChange={setWorkTeamFilters}
placeholder="작업반장"
searchPlaceholder="작업반장 검색..."
className="w-[130px]"
/>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="발주관리"
description="발주 스케줄 및 목록을 관리합니다"
icon={Package}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="발주번호, 거래처, 현장명, 발주담당 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedOrders}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedOrders.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
// 달력 섹션 추가
beforeTableContent={
<div className="w-full flex-shrink-0 mb-6">
<h3 className="text-sm font-semibold text-muted-foreground mb-3"> </h3>
<ScheduleCalendar
events={calendarEvents}
badges={calendarBadges}
currentDate={calendarDate}
selectedDate={selectedCalendarDate}
onDateClick={handleCalendarDateClick}
onEventClick={handleCalendarEventClick}
onMonthChange={handleCalendarMonthChange}
filterSlot={calendarFilterSlot}
maxEventsPerDay={3}
weekStartsOn={0}
isLoading={isLoading}
/>
</div>
}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,381 @@
'use server';
import type { Order, OrderStats, OrderType, OrderDetail, OrderDetailFormData } from './types';
import { MOCK_ORDER_DETAIL } from './types';
import { format, addDays, subDays, subMonths } from 'date-fns';
/**
* 목업 발주 데이터 생성
* - types.ts의 MOCK 옵션들과 정확히 일치해야 필터가 동작함
*/
function generateMockOrders(): Order[] {
// types.ts MOCK_PARTNERS와 일치
const partners = [
{ id: '1', name: '(주)대한건설' },
{ id: '2', name: '삼성물산' },
{ id: '3', name: '현대건설' },
{ id: '4', name: 'GS건설' },
{ id: '5', name: '대림산업' },
];
// types.ts MOCK_SITES와 일치
const sites = [
'강남 오피스빌딩 신축',
'판교 데이터센터',
'송도 물류센터',
'인천공항 터미널',
'부산항 창고',
];
const names = [
'철근 HD13',
'철근 HD16',
'철근 HD19',
'철근 HD22',
'H빔 300x300',
'H빔 200x200',
'콘크리트 25-21-12',
'레미콘 배합',
];
const items = [
'철근 HD13',
'철근 HD16',
'철근 HD19',
'H빔',
'레미콘',
'앵커볼트',
'데크플레이트',
'용접봉',
];
// types.ts MOCK_CONSTRUCTION_PM과 일치
const constructionPMs = ['홍길동', '김철수', '이영희', '박민수'];
// types.ts MOCK_ORDER_MANAGERS와 일치
const orderManagers = ['김담당', '이담당', '박담당', '최담당'];
// types.ts MOCK_ORDER_COMPANIES와 일치
const orderCompanies = ['A건설', 'B철강', 'C자재', 'D산업'];
// types.ts MOCK_WORK_TEAM_LEADERS와 일치
const workTeamLeaders = ['이반장', '김반장', '박반장', '최반장'];
const orderTypes: OrderType[] = ['steel_bar', 'material', 'outsourcing'];
const statuses: Order['status'][] = ['waiting', 'order_complete', 'delivery_scheduled', 'delivery_complete'];
const orders: Order[] = [];
const today = new Date();
for (let i = 0; i < 50; i++) {
const partner = partners[Math.floor(Math.random() * partners.length)];
const site = sites[Math.floor(Math.random() * sites.length)];
const status = statuses[Math.floor(Math.random() * statuses.length)];
const orderType = orderTypes[Math.floor(Math.random() * orderTypes.length)];
const periodStart = subMonths(today, Math.floor(Math.random() * 3));
const periodEnd = addDays(periodStart, Math.floor(Math.random() * 30) + 10);
const orderDate = subDays(periodStart, Math.floor(Math.random() * 5));
const constructionStartDate = addDays(periodStart, Math.floor(Math.random() * 5));
const plannedDelivery = addDays(orderDate, Math.floor(Math.random() * 14) + 3);
const actualDelivery = status === 'delivery_complete'
? format(addDays(plannedDelivery, Math.floor(Math.random() * 5) - 2), 'yyyy-MM-dd')
: null;
orders.push({
id: `order-${i + 1}`,
contractNumber: `CT-${2026}-${String(i + 1).padStart(4, '0')}`,
partnerId: partner.id,
partnerName: partner.name,
siteName: site,
name: names[Math.floor(Math.random() * names.length)],
constructionPM: constructionPMs[Math.floor(Math.random() * constructionPMs.length)],
orderManager: orderManagers[Math.floor(Math.random() * orderManagers.length)],
orderNumber: `ORD-${2026}-${String(i + 1).padStart(4, '0')}`,
orderCompany: orderCompanies[Math.floor(Math.random() * orderCompanies.length)],
workTeamLeader: workTeamLeaders[Math.floor(Math.random() * workTeamLeaders.length)],
constructionStartDate: format(constructionStartDate, 'yyyy-MM-dd'),
orderType,
item: items[Math.floor(Math.random() * items.length)],
quantity: Math.floor(Math.random() * 100) + 1,
orderDate: format(orderDate, 'yyyy-MM-dd'),
plannedDeliveryDate: format(plannedDelivery, 'yyyy-MM-dd'),
actualDeliveryDate: actualDelivery,
status,
periodStart: format(periodStart, 'yyyy-MM-dd'),
periodEnd: format(periodEnd, 'yyyy-MM-dd'),
createdAt: format(subDays(periodStart, Math.floor(Math.random() * 10)), 'yyyy-MM-dd\'T\'HH:mm:ss'),
updatedAt: format(today, 'yyyy-MM-dd\'T\'HH:mm:ss'),
});
}
return orders;
}
// 캐시된 목업 데이터
let cachedOrders: Order[] | null = null;
function getMockOrders(): Order[] {
if (!cachedOrders) {
cachedOrders = generateMockOrders();
}
return cachedOrders;
}
/**
* 발주 목록 조회
*/
export async function getOrderList(params?: {
size?: number;
page?: number;
startDate?: string;
endDate?: string;
status?: string;
partnerId?: string;
search?: string;
}): Promise<{
success: boolean;
data?: { items: Order[]; total: number };
error?: string;
}> {
try {
// 목업 데이터
let orders = getMockOrders();
// 날짜 필터
if (params?.startDate && params?.endDate) {
orders = orders.filter((order) => {
return order.periodStart >= params.startDate! && order.periodEnd <= params.endDate!;
});
}
// 상태 필터
if (params?.status && params.status !== 'all') {
orders = orders.filter((order) => order.status === params.status);
}
// 거래처 필터
if (params?.partnerId && params.partnerId !== 'all') {
orders = orders.filter((order) => order.partnerId === params.partnerId);
}
// 검색
if (params?.search) {
const search = params.search.toLowerCase();
orders = orders.filter(
(order) =>
order.orderNumber.toLowerCase().includes(search) ||
order.partnerName.toLowerCase().includes(search) ||
order.siteName.toLowerCase().includes(search) ||
order.orderManager.toLowerCase().includes(search)
);
}
// 페이지네이션
const page = params?.page || 1;
const size = params?.size || 1000;
const start = (page - 1) * size;
const paginatedOrders = orders.slice(start, start + size);
return {
success: true,
data: {
items: paginatedOrders,
total: orders.length,
},
};
} catch {
return {
success: false,
error: '발주 목록 조회에 실패했습니다.',
};
}
}
/**
* 발주 통계 조회
*/
export async function getOrderStats(): Promise<{
success: boolean;
data?: OrderStats;
error?: string;
}> {
try {
const orders = getMockOrders();
const stats: OrderStats = {
total: orders.length,
waiting: orders.filter((o) => o.status === 'waiting').length,
orderComplete: orders.filter((o) => o.status === 'order_complete').length,
deliveryScheduled: orders.filter((o) => o.status === 'delivery_scheduled').length,
deliveryComplete: orders.filter((o) => o.status === 'delivery_complete').length,
};
return {
success: true,
data: stats,
};
} catch {
return {
success: false,
error: '발주 통계 조회에 실패했습니다.',
};
}
}
/**
* 발주 삭제
*/
export async function deleteOrder(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
// 목업: 실제로는 API 호출
if (cachedOrders) {
cachedOrders = cachedOrders.filter((o) => o.id !== id);
}
return { success: true };
} catch {
return {
success: false,
error: '발주 삭제에 실패했습니다.',
};
}
}
/**
* 발주 일괄 삭제
*/
export async function deleteOrders(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
try {
// 목업: 실제로는 API 호출
if (cachedOrders) {
const beforeCount = cachedOrders.length;
cachedOrders = cachedOrders.filter((o) => !ids.includes(o.id));
const deletedCount = beforeCount - cachedOrders.length;
return {
success: true,
deletedCount,
};
}
return {
success: true,
deletedCount: ids.length,
};
} catch {
return {
success: false,
error: '발주 일괄 삭제에 실패했습니다.',
};
}
}
/**
* 발주 상세 조회
*/
export async function getOrderDetail(id: string): Promise<{
success: boolean;
data?: Order;
error?: string;
}> {
try {
const orders = getMockOrders();
const order = orders.find((o) => o.id === id);
if (!order) {
return {
success: false,
error: '발주를 찾을 수 없습니다.',
};
}
return {
success: true,
data: order,
};
} catch {
return {
success: false,
error: '발주 상세 조회에 실패했습니다.',
};
}
}
/**
* 발주 상세 조회 (전체 정보)
*/
export async function getOrderDetailFull(id: string): Promise<{
success: boolean;
data?: OrderDetail;
error?: string;
}> {
try {
// 목업: 실제로는 API 호출
// 임시로 MOCK_ORDER_DETAIL 반환
const mockDetail: OrderDetail = {
...MOCK_ORDER_DETAIL,
id,
};
return {
success: true,
data: mockDetail,
};
} catch {
return {
success: false,
error: '발주 상세 조회에 실패했습니다.',
};
}
}
/**
* 발주 수정
*/
export async function updateOrder(
id: string,
data: OrderDetailFormData
): Promise<{
success: boolean;
error?: string;
}> {
try {
// 목업: 실제로는 API 호출
console.log('Updating order:', id, data);
return { success: true };
} catch {
return {
success: false,
error: '발주 수정에 실패했습니다.',
};
}
}
/**
* 발주 복제
*/
export async function duplicateOrder(id: string): Promise<{
success: boolean;
newId?: string;
error?: string;
}> {
try {
// 목업: 실제로는 API 호출
const newId = `order-${Date.now()}`;
console.log('Duplicating order:', id, '-> new id:', newId);
return {
success: true,
newId,
};
} catch {
return {
success: false,
error: '발주 복제에 실패했습니다.',
};
}
}

View File

@@ -0,0 +1,51 @@
/**
* 발주관리 컴포넌트
*/
export { default as OrderManagementListClient } from './OrderManagementListClient';
export { default as OrderDetailForm } from './OrderDetailForm';
export type {
Order,
OrderStats,
OrderStatus,
OrderType,
OrderDetail,
OrderDetailItem,
OrderDetailFormData,
OrderScheduleEvent,
} from './types';
export {
ORDER_STATUS_OPTIONS,
ORDER_STATUS_LABELS,
ORDER_STATUS_STYLES,
ORDER_STATUS_CALENDAR_COLORS,
ORDER_SORT_OPTIONS,
ORDER_TYPE_OPTIONS,
ORDER_TYPE_LABELS,
DELIVERY_LOCATION_TYPE_OPTIONS,
DELIVERY_LOCATION_TYPE_LABELS,
MOCK_WORK_TEAM_LEADERS,
MOCK_PARTNERS,
MOCK_CONSTRUCTION_PM,
MOCK_ORDER_MANAGERS,
MOCK_ORDER_COMPANIES,
getEmptyOrderDetailItem,
getEmptyOrderDetailFormData,
orderDetailToFormData,
MOCK_ORDER_DETAIL,
} from './types';
export {
getOrderList,
getOrderStats,
deleteOrder,
deleteOrders,
getOrderDetail,
getOrderDetailFull,
updateOrder,
duplicateOrder,
} from './actions';
export { OrderDocumentModal } from './modals/OrderDocumentModal';

View File

@@ -0,0 +1,346 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Edit, Trash2, Printer, X } from 'lucide-react';
import { toast } from 'sonner';
import { useRouter } from 'next/navigation';
import { printArea } from '@/lib/print-utils';
import type { OrderDetail, OrderDetailItem } from '../types';
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
}
// 카테고리별로 품목 그룹화
function groupItemsByCategory(items: OrderDetailItem[]): Map<string, OrderDetailItem[]> {
const grouped = new Map<string, OrderDetailItem[]>();
items.forEach((item) => {
// name 필드를 카테고리로 사용 (실제로는 categoryName 필드가 있어야 함)
const categoryKey = item.name || '기타';
if (!grouped.has(categoryKey)) {
grouped.set(categoryKey, []);
}
grouped.get(categoryKey)!.push(item);
});
return grouped;
}
// 이미지 포함 여부 확인
function hasImage(items: OrderDetailItem[]): boolean {
return items.some((item) => item.imageUrl && item.imageUrl.trim() !== '');
}
interface OrderDocumentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
order: OrderDetail;
}
export function OrderDocumentModal({
open,
onOpenChange,
order,
}: OrderDocumentModalProps) {
const router = useRouter();
// 수정
const handleEdit = () => {
onOpenChange(false);
router.push(`/ko/juil/order/order-management/${order.id}/edit`);
};
// 삭제
const handleDelete = () => {
toast.info('삭제 기능은 준비 중입니다.');
};
// 인쇄
const handlePrint = () => {
printArea({ title: '발주서 인쇄' });
};
// 카테고리별 그룹화
const groupedItems = groupItemsByCategory(order.orderItems || []);
const categories = Array.from(groupedItems.entries());
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold mb-2"></h1>
<div className="text-sm text-gray-600">
: {order.orderNumber} | : {formatDate(order.orderDate)}
</div>
</div>
{/* 기본 정보 테이블 */}
<table className="w-full border-collapse border border-gray-300 text-sm mb-8">
<tbody>
{/* 출고일 / 작업팀 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-28 font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">
{formatDate(order.plannedDeliveryDate)}
</td>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center w-28 font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">
{order.workTeamLeader || '-'}
</td>
</tr>
{/* 현장명 / 연락처 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">{order.siteName || '-'}</td>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">-</td>
</tr>
{/* 화물 도착지 / 발주담당자 */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">{order.deliveryAddress || '-'}</td>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-center font-medium">
</th>
<td className="border border-gray-300 px-4 py-3">
{order.orderManager || '-'}
</td>
</tr>
</tbody>
</table>
{/* 카테고리별 발주 품목 */}
{categories.map(([categoryName, items]) => {
const showImageLayout = hasImage(items);
return (
<div key={categoryName} className="mb-8">
{/* 카테고리 헤더 */}
<div className="flex items-center gap-2 mb-4">
<span className="font-bold text-lg"> {categoryName}</span>
</div>
{showImageLayout ? (
// 이미지 포함 시 2열 레이아웃
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="grid grid-cols-2 gap-4 border border-gray-300">
{/* 좌측: 이미지 영역 */}
<div className="p-4 flex flex-col">
<table className="w-full border-collapse text-sm mb-4">
<tbody>
<tr>
<th className="border border-gray-300 px-3 py-2 bg-gray-50 text-center w-20">
</th>
<td className="border border-gray-300 px-3 py-2">
{item.name || '-'}
</td>
</tr>
<tr>
<th className="border border-gray-300 px-3 py-2 bg-gray-50 text-center">
</th>
<td className="border border-gray-300 px-3 py-2">
{item.product || '-'}
</td>
</tr>
</tbody>
</table>
{/* 이미지 표시 영역 */}
<div className="flex-1 border border-gray-200 rounded flex items-center justify-center min-h-[150px] bg-gray-50">
{item.imageUrl ? (
<img
src={item.imageUrl}
alt={item.name || '품목 이미지'}
className="max-w-full max-h-[200px] object-contain"
/>
) : (
<span className="text-gray-400">IMG</span>
)}
</div>
</div>
{/* 우측: 상세 정보 테이블 */}
<div className="p-4">
<table className="w-full border-collapse text-sm">
<tbody>
<tr>
<th className="border border-gray-300 px-3 py-2 bg-gray-50 text-center w-20">
</th>
<td className="border border-gray-300 px-3 py-2">
{item.name || '-'}
</td>
</tr>
<tr>
<th className="border border-gray-300 px-3 py-2 bg-gray-50 text-center">
</th>
<td className="border border-gray-300 px-3 py-2">
{item.product || '-'}
</td>
</tr>
<tr>
<th className="border border-gray-300 px-3 py-2 bg-gray-50 text-center">
</th>
<td className="border border-gray-300 px-3 py-2">
{item.width || '-'}
</td>
</tr>
<tr>
<th className="border border-gray-300 px-3 py-2 bg-gray-50 text-center">
</th>
<td className="border border-gray-300 px-3 py-2">
{item.height || '-'}
</td>
</tr>
<tr>
<th className="border border-gray-300 px-3 py-2 bg-gray-50 text-center">
</th>
<td className="border border-gray-300 px-3 py-2">
{item.quantity} {item.unit}
</td>
</tr>
<tr>
<th className="border border-gray-300 px-3 py-2 bg-gray-50 text-center">
</th>
<td className="border border-gray-300 px-3 py-2">
{item.remark || '-'}
</td>
</tr>
</tbody>
</table>
</div>
</div>
))}
</div>
) : (
// 이미지 없을 시 일반 테이블 레이아웃
<table className="w-full border-collapse border border-gray-300 text-sm">
<thead>
<tr className="bg-gray-50">
<th className="border border-gray-300 px-3 py-2 text-center"></th>
<th className="border border-gray-300 px-3 py-2 text-center"></th>
<th className="border border-gray-300 px-3 py-2 text-center w-20"></th>
<th className="border border-gray-300 px-3 py-2 text-center w-20"></th>
<th className="border border-gray-300 px-3 py-2 text-center w-20"></th>
<th className="border border-gray-300 px-3 py-2 text-center w-16"></th>
<th className="border border-gray-300 px-3 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td className="border border-gray-300 px-3 py-2">{item.name || '-'}</td>
<td className="border border-gray-300 px-3 py-2">{item.product || '-'}</td>
<td className="border border-gray-300 px-3 py-2 text-center">
{item.width || '-'}
</td>
<td className="border border-gray-300 px-3 py-2 text-center">
{item.height || '-'}
</td>
<td className="border border-gray-300 px-3 py-2 text-center">
{item.quantity}
</td>
<td className="border border-gray-300 px-3 py-2 text-center">
{item.unit || '-'}
</td>
<td className="border border-gray-300 px-3 py-2">{item.remark || '-'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
})}
{/* 품목이 없는 경우 */}
{categories.length === 0 && (
<div className="text-center text-gray-400 py-8 border border-gray-200 rounded">
.
</div>
)}
{/* 비고 영역 - 내용이 있을 때만 표시 */}
{order.memo && (
<div className="mt-8">
<div className="flex items-center gap-2 mb-4">
<span className="font-bold text-lg"> </span>
</div>
<div className="whitespace-pre-wrap text-sm">
{order.memo}
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,660 @@
/**
* 발주관리 타입 정의
*/
/**
* 발주 상태
*/
export type OrderStatus = 'waiting' | 'order_complete' | 'delivery_scheduled' | 'delivery_complete';
/**
* 발주 구분 (타입)
*/
export type OrderType = 'steel_bar' | 'material' | 'outsourcing';
/**
* 발주 데이터
*/
export interface Order {
/** 발주 ID */
id: string;
/** 계약번호 */
contractNumber: string;
/** 거래처 ID */
partnerId: string;
/** 거래처명 */
partnerName: string;
/** 현장명 */
siteName: string;
/** 명칭 */
name: string;
/** 공사PM */
constructionPM: string;
/** 발주담당자 */
orderManager: string;
/** 발주번호 */
orderNumber: string;
/** 발주처명 */
orderCompany: string;
/** 작업반장 */
workTeamLeader: string;
/** 시공투입일 */
constructionStartDate: string;
/** 구분 (발주 타입) */
orderType: OrderType;
/** 품목 */
item: string;
/** 수량 */
quantity: number;
/** 발주일 */
orderDate: string;
/** 계획납품일 */
plannedDeliveryDate: string;
/** 실제 납품일 */
actualDeliveryDate: string | null;
/** 상태 */
status: OrderStatus;
/** 기간 (시작일) - 달력용 */
periodStart: string;
/** 기간 (종료일) - 달력용 */
periodEnd: string;
/** 생성일 */
createdAt: string;
/** 수정일 */
updatedAt: string;
}
/**
* 발주 통계
*/
export interface OrderStats {
/** 전체 */
total: number;
/** 발주대기 */
waiting: number;
/** 발주완료 */
orderComplete: number;
/** 납품예정 */
deliveryScheduled: number;
/** 납품완료 */
deliveryComplete: number;
}
/**
* 발주 상태 옵션
*/
export const ORDER_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'waiting', label: '발주대기' },
{ value: 'order_complete', label: '발주완료' },
{ value: 'delivery_scheduled', label: '납품예정' },
{ value: 'delivery_complete', label: '납품완료' },
] as const;
/**
* 발주 상태 라벨
*/
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
waiting: '발주대기',
order_complete: '발주완료',
delivery_scheduled: '납품예정',
delivery_complete: '납품완료',
};
/**
* 발주 상태 스타일
*/
export const ORDER_STATUS_STYLES: Record<OrderStatus, string> = {
waiting: 'bg-yellow-100 text-yellow-800',
order_complete: 'bg-blue-100 text-blue-800',
delivery_scheduled: 'bg-purple-100 text-purple-800',
delivery_complete: 'bg-green-100 text-green-800',
};
/**
* 발주 상태별 달력 색상
*/
export const ORDER_STATUS_CALENDAR_COLORS: Record<OrderStatus, string> = {
waiting: 'yellow',
order_complete: 'blue',
delivery_scheduled: 'purple',
delivery_complete: 'green',
};
/**
* 발주 구분 (타입) 옵션
*/
export const ORDER_TYPE_OPTIONS: FilterOption[] = [
{ value: 'steel_bar', label: '강봉발주' },
{ value: 'material', label: '화자재발주' },
{ value: 'outsourcing', label: '외주발주' },
];
/**
* 발주 구분 (타입) 라벨
*/
export const ORDER_TYPE_LABELS: Record<OrderType, string> = {
steel_bar: '강봉발주',
material: '화자재발주',
outsourcing: '외주발주',
};
/**
* 정렬 옵션
*/
export const ORDER_SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '오래된순' },
{ value: 'partnerNameAsc', label: '거래처명 ↑' },
{ value: 'partnerNameDesc', label: '거래처명 ↓' },
{ value: 'siteNameAsc', label: '현장명 ↑' },
{ value: 'siteNameDesc', label: '현장명 ↓' },
{ value: 'deliveryDateAsc', label: '납품일 ↑' },
{ value: 'deliveryDateDesc', label: '납품일 ↓' },
] as const;
/**
* 필터 옵션 공통 타입
*/
export interface FilterOption {
value: string;
label: string;
}
/**
* 목업 거래처 목록 (검색&다중선택 지원)
* - '전체' 옵션은 MultiSelectCombobox에서 자동 제공
*/
export const MOCK_PARTNERS: FilterOption[] = [
{ value: '1', label: '(주)대한건설' },
{ value: '2', label: '삼성물산' },
{ value: '3', label: '현대건설' },
{ value: '4', label: 'GS건설' },
{ value: '5', label: '대림산업' },
];
/**
* 목업 현장 목록 (검색&다중선택 지원)
* - '전체' 옵션은 MultiSelectCombobox에서 자동 제공
*/
export const MOCK_SITES: FilterOption[] = [
{ value: '1', label: '강남 오피스빌딩 신축' },
{ value: '2', label: '판교 데이터센터' },
{ value: '3', label: '송도 물류센터' },
{ value: '4', label: '인천공항 터미널' },
{ value: '5', label: '부산항 창고' },
];
/**
* 목업 공사PM 목록 (검색&다중선택 지원)
*/
export const MOCK_CONSTRUCTION_PM: FilterOption[] = [
{ value: '1', label: '홍길동' },
{ value: '2', label: '김철수' },
{ value: '3', label: '이영희' },
{ value: '4', label: '박민수' },
];
/**
* 목업 발주담당자 목록 (검색&다중선택 지원)
*/
export const MOCK_ORDER_MANAGERS: FilterOption[] = [
{ value: '1', label: '김담당' },
{ value: '2', label: '이담당' },
{ value: '3', label: '박담당' },
{ value: '4', label: '최담당' },
];
/**
* 목업 발주처 목록 (검색&다중선택 지원)
*/
export const MOCK_ORDER_COMPANIES: FilterOption[] = [
{ value: '1', label: 'A건설' },
{ value: '2', label: 'B철강' },
{ value: '3', label: 'C자재' },
{ value: '4', label: 'D산업' },
];
/**
* 목업 작업반장 목록 (검색&다중선택 지원)
* - '전체' 옵션은 MultiSelectCombobox에서 자동 제공
*/
export const MOCK_WORK_TEAM_LEADERS: FilterOption[] = [
{ value: '1', label: '이반장' },
{ value: '2', label: '김반장' },
{ value: '3', label: '박반장' },
{ value: '4', label: '최반장' },
];
// ============================================
// 발주 상세 관련 타입 및 상수
// ============================================
/**
* 발주 상세 아이템 (발주 품목) - 테이블 로우
*/
export interface OrderDetailItem {
id: string;
/** 작업반장 */
workTeamLeader: string;
/** 시공투입일 */
constructionStartDate: string;
/** 시공완료일 */
constructionEndDate: string;
/** 명칭 */
name: string;
/** 제품 */
product: string;
/** 가로 */
width: number;
/** 세로 */
height: number;
/** 항목명 (품목관리에서 가져옴) */
itemName: string;
/** 수량 */
quantity: number;
/** 단위 */
unit: string;
/** 비고 */
remark: string;
/** 이미지 URL */
imageUrl: string;
/** 발주일 */
orderDate: string;
/** 계획 납품일 */
plannedDeliveryDate: string;
/** 실제 납품일 */
actualDeliveryDate: string;
/** 상태 */
status: OrderStatus;
}
/**
* 발주 상세 카테고리 섹션
* - 카테고리별로 테이블 섹션을 관리
*/
export interface OrderDetailCategory {
id: string;
/** 카테고리 ID */
categoryId: string;
/** 카테고리명 */
categoryName: string;
/** 해당 카테고리의 발주 품목 목록 */
items: OrderDetailItem[];
}
/**
* 발주 상세 데이터
*/
export interface OrderDetail extends Order {
/** 발주처 ID */
orderCompanyId: string;
/** 화물도착지 타입 */
deliveryLocationType: 'site' | 'warehouse' | 'direct';
/** 화물도착지 주소 */
deliveryAddress: string;
/** 계약 ID */
contractId: string;
/** 공사PM ID */
constructionPMId: string;
/** 공사담당자 목록 */
constructionManagers: string[];
/** 발주 상세 품목 목록 */
orderItems: OrderDetailItem[];
/** 비고 */
memo: string;
/** 발주 스케줄 이벤트 (달력용) */
scheduleEvents: OrderScheduleEvent[];
}
/**
* 발주 스케줄 이벤트 (달력용)
*/
export interface OrderScheduleEvent {
id: string;
title: string;
startDate: string;
endDate: string;
orderType: OrderType;
color: string;
}
/**
* 발주 상세 폼 데이터
*/
export interface OrderDetailFormData {
/** 발주번호 */
orderNumber: string;
/** 발주처 ID */
orderCompanyId: string;
/** 구분 (발주 타입) */
orderType: OrderType;
/** 상태 */
status: OrderStatus;
/** 발주담당자 */
orderManager: string;
/** 화물도착지 주소 */
deliveryAddress: string;
/** 거래처 ID */
partnerId: string;
/** 거래처명 */
partnerName: string;
/** 현장명 */
siteName: string;
/** 계약번호 */
contractNumber: string;
/** 계약 ID */
contractId: string;
/** 공사PM ID */
constructionPMId: string;
/** 공사PM 이름 */
constructionPM: string;
/** 공사담당자 목록 */
constructionManagers: string[];
/** 발주 상세 카테고리 목록 */
orderCategories: OrderDetailCategory[];
/** 비고 */
memo: string;
/** 기간 (시작일) */
periodStart: string;
/** 기간 (종료일) */
periodEnd: string;
}
/**
* 화물도착지 타입 옵션
*/
export const DELIVERY_LOCATION_TYPE_OPTIONS: FilterOption[] = [
{ value: 'site', label: '현장' },
{ value: 'warehouse', label: '창고' },
{ value: 'direct', label: '직배송' },
];
/**
* 화물도착지 타입 라벨
*/
export const DELIVERY_LOCATION_TYPE_LABELS: Record<'site' | 'warehouse' | 'direct', string> = {
site: '현장',
warehouse: '창고',
direct: '직배송',
};
/**
* 목업 카테고리 목록
*/
export const MOCK_CATEGORIES: FilterOption[] = [
{ value: '1', label: '강봉' },
{ value: '2', label: '화자재' },
{ value: '3', label: '외주' },
{ value: '4', label: '철근' },
{ value: '5', label: '콘크리트' },
];
/**
* 목업 항목명 목록 (품목관리에서 가져오는 데이터)
*/
export const MOCK_ITEM_NAMES: FilterOption[] = [
{ value: '1', label: '이형봉강 SD400 D16' },
{ value: '2', label: '이형봉강 SD400 D19' },
{ value: '3', label: '원형봉강 SR235' },
{ value: '4', label: '콘크리트 25-21-150' },
{ value: '5', label: '철근 가공품' },
];
/**
* 빈 발주 상세 품목 생성
*/
export function getEmptyOrderDetailItem(): OrderDetailItem {
return {
id: String(Date.now()),
workTeamLeader: '',
constructionStartDate: '',
constructionEndDate: '',
name: '',
product: '',
width: 0,
height: 0,
itemName: '',
quantity: 0,
unit: '',
remark: '',
imageUrl: '',
orderDate: '',
plannedDeliveryDate: '',
actualDeliveryDate: '',
status: 'waiting',
};
}
/**
* 빈 발주 상세 카테고리 생성
*/
export function getEmptyOrderDetailCategory(): OrderDetailCategory {
return {
id: String(Date.now()),
categoryId: '',
categoryName: '',
items: [],
};
}
/**
* 빈 발주 상세 폼 데이터 생성
*/
export function getEmptyOrderDetailFormData(): OrderDetailFormData {
return {
orderNumber: '',
orderCompanyId: '',
orderType: 'steel_bar',
status: 'waiting',
orderManager: '',
deliveryAddress: '',
partnerId: '',
partnerName: '',
siteName: '',
contractNumber: '',
contractId: '',
constructionPMId: '',
constructionPM: '',
constructionManagers: [],
orderCategories: [],
memo: '',
periodStart: '',
periodEnd: '',
};
}
/**
* OrderDetail을 폼 데이터로 변환
*/
export function orderDetailToFormData(detail: OrderDetail): OrderDetailFormData {
// orderItems를 카테고리별로 그룹핑
const categoryMap = new Map<string, OrderDetailCategory>();
detail.orderItems.forEach((item) => {
// 임시로 'default' 카테고리 사용 (실제로는 item에 categoryId가 있어야 함)
const categoryId = 'default';
const categoryName = '기본 카테고리';
if (!categoryMap.has(categoryId)) {
categoryMap.set(categoryId, {
id: categoryId,
categoryId,
categoryName,
items: [],
});
}
categoryMap.get(categoryId)!.items.push(item);
});
return {
orderNumber: detail.orderNumber,
orderCompanyId: detail.orderCompanyId,
orderType: detail.orderType,
status: detail.status,
orderManager: detail.orderManager,
deliveryAddress: detail.deliveryAddress,
partnerId: detail.partnerId,
partnerName: detail.partnerName,
siteName: detail.siteName,
contractNumber: detail.contractNumber,
contractId: detail.contractId,
constructionPMId: detail.constructionPMId,
constructionPM: detail.constructionPM,
constructionManagers: detail.constructionManagers,
orderCategories: Array.from(categoryMap.values()),
memo: detail.memo,
periodStart: detail.periodStart,
periodEnd: detail.periodEnd,
};
}
/**
* 목업 발주 상세 데이터
*/
export const MOCK_ORDER_DETAIL: OrderDetail = {
id: '1',
contractNumber: 'CT-2025-001',
partnerId: '1',
partnerName: '(주)대한건설',
siteName: '강남 오피스빌딩 신축',
name: '강봉 발주',
constructionPM: '홍길동',
orderManager: '김담당',
orderNumber: 'ORD-2025-001',
orderCompany: 'A건설',
workTeamLeader: '이반장',
constructionStartDate: '2025-01-15',
orderType: 'steel_bar',
item: '강봉',
quantity: 100,
orderDate: '2025-01-10',
plannedDeliveryDate: '2025-01-20',
actualDeliveryDate: null,
status: 'order_complete',
periodStart: '2025-01-10',
periodEnd: '2025-01-20',
createdAt: '2025-01-10T09:00:00Z',
updatedAt: '2025-01-10T09:00:00Z',
orderCompanyId: '1',
deliveryLocationType: 'site',
deliveryAddress: '서울시 강남구 테헤란로 123',
contractId: '1',
constructionPMId: '1',
constructionManagers: ['홍길동', '김철수', '이영희'],
orderItems: [
// 카테고리 1: 이미지 없음 → 일반 테이블 레이아웃
{
id: '1',
workTeamLeader: '이반장',
constructionStartDate: '2025-01-15',
constructionEndDate: '2025-01-20',
name: '카테고리명',
product: '제품명',
width: 300,
height: 500,
itemName: 'FSSB01(주차장)',
quantity: 1,
unit: 'EA',
remark: '',
imageUrl: '',
orderDate: '2025-01-10',
plannedDeliveryDate: '2025-01-18',
actualDeliveryDate: '',
status: 'order_complete',
},
{
id: '2',
workTeamLeader: '김반장',
constructionStartDate: '2025-01-16',
constructionEndDate: '2025-01-22',
name: '카테고리명',
product: '제품명',
width: 300,
height: 500,
itemName: 'FSSB01(주차장)',
quantity: 1,
unit: 'EA',
remark: '',
imageUrl: '',
orderDate: '2025-01-12',
plannedDeliveryDate: '2025-01-20',
actualDeliveryDate: '',
status: 'waiting',
},
// 카테고리 2: 이미지 있음 → 2열 레이아웃
{
id: '3',
workTeamLeader: '이반장',
constructionStartDate: '2025-01-15',
constructionEndDate: '2025-01-20',
name: '카테고리명2',
product: '제품명',
width: 300,
height: 500,
itemName: 'FSSB01(주차장)',
quantity: 1,
unit: 'EA',
remark: '',
imageUrl: 'https://placehold.co/400x300/e2e8f0/64748b?text=IMG',
orderDate: '2025-01-10',
plannedDeliveryDate: '2025-01-18',
actualDeliveryDate: '',
status: 'order_complete',
},
{
id: '4',
workTeamLeader: '김반장',
constructionStartDate: '2025-01-16',
constructionEndDate: '2025-01-22',
name: '카테고리명2',
product: '제품명',
width: 300,
height: 500,
itemName: 'FSSB01(주차장)',
quantity: 1,
unit: 'EA',
remark: '',
imageUrl: 'https://placehold.co/400x300/e2e8f0/64748b?text=IMG',
orderDate: '2025-01-12',
plannedDeliveryDate: '2025-01-20',
actualDeliveryDate: '',
status: 'waiting',
},
],
memo: '내용',
scheduleEvents: [
{
id: '1',
title: '홍길동 - 현장명 / 발주번호',
startDate: '2026-01-05',
endDate: '2026-01-09',
orderType: 'steel_bar',
color: 'blue',
},
{
id: '2',
title: '홍길동 - 현장명 / 발주번호',
startDate: '2026-01-05',
endDate: '2026-01-07',
orderType: 'material',
color: 'blue',
},
{
id: '3',
title: '홍길동 - 현장명 / 발주번호',
startDate: '2026-01-06',
endDate: '2026-01-16',
orderType: 'outsourcing',
color: 'blue',
},
{
id: '4',
title: '홍길동 - 현장명 / 발주번호',
startDate: '2026-01-07',
endDate: '2026-01-18',
orderType: 'steel_bar',
color: 'blue',
},
],
};

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useCallback, useMemo, useRef } from 'react';
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Building2, Plus, X, Loader2, Upload, FileText, Image as ImageIcon } from 'lucide-react';
import { Building2, Plus, X, Loader2, Upload, FileText, Image as ImageIcon, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -41,6 +41,31 @@ import {
partnerToFormData,
} from './types';
// 목업 문서 목록 (상세 모드에서 다운로드 버튼 테스트용)
const MOCK_DOCUMENTS: PartnerDocument[] = [
{
id: '1',
fileName: '사업자등록증.pdf',
fileUrl: '#',
fileSize: 1024000, // 1MB
uploadedAt: '2024-12-15T10:00:00',
},
{
id: '2',
fileName: '통장사본.jpg',
fileUrl: '#',
fileSize: 512000, // 500KB
uploadedAt: '2024-12-16T14:30:00',
},
{
id: '3',
fileName: '인감증명서.pdf',
fileUrl: '#',
fileSize: 768000, // 750KB
uploadedAt: '2024-12-18T09:00:00',
},
];
interface PartnerFormProps {
mode: 'view' | 'edit' | 'new';
partnerId?: string;
@@ -75,6 +100,19 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
// 상세/수정 모드에서 목데이터 초기화
useEffect(() => {
if (initialData) {
setFormData((prev) => ({
...prev,
// 문서 목데이터
documents: prev.documents.length === 0 ? MOCK_DOCUMENTS : prev.documents,
// 회사 로고 목데이터
logoUrl: prev.logoUrl || prev.logoBlob ? prev.logoUrl : 'https://placehold.co/750x250/f97316/white?text=Company+Logo',
}));
}
}, [initialData]);
// Daum 우편번호 서비스
const { openPostcode } = useDaumPostcode({
onComplete: (result) => {
@@ -763,7 +801,23 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
</p>
</div>
</div>
{!isViewMode && (
{isViewMode ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = doc.fileUrl;
link.download = doc.fileName;
link.click();
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
}}
>
<Download className="h-4 w-4 mr-1" />
</Button>
) : (
<Button
type="button"
variant="ghost"

View File

@@ -0,0 +1,503 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { DollarSign } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Pricing, PricingStatus } from './types';
import { PRICING_STATUS_LABELS } from './types';
import {
getPricingDetail,
createPricing,
updatePricing,
deletePricing,
getVendorList,
} from './actions';
interface PricingDetailClientProps {
id?: string;
mode: 'view' | 'create' | 'edit';
}
interface FormData {
itemType: string;
category: string;
itemName: string;
spec: string;
unit: string;
division: string;
vendor: string;
purchasePrice: number;
marginRate: number;
sellingPrice: number;
status: PricingStatus;
note: string;
}
const initialFormData: FormData = {
itemType: '',
category: '',
itemName: '',
spec: '',
unit: '',
division: '',
vendor: '',
purchasePrice: 0,
marginRate: 0,
sellingPrice: 0,
status: 'in_use',
note: '',
};
export default function PricingDetailClient({ id, mode }: PricingDetailClientProps) {
const router = useRouter();
const [pricing, setPricing] = useState<Pricing | null>(null);
const [formData, setFormData] = useState<FormData>(initialFormData);
const [vendors, setVendors] = useState<{ id: string; name: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const isEditMode = mode === 'edit';
// 데이터 로드
useEffect(() => {
const loadData = async () => {
setIsLoading(true);
try {
// 거래처 목록 로드
const vendorResult = await getVendorList();
if (vendorResult.success && vendorResult.data) {
setVendors(vendorResult.data);
}
// 상세 데이터 로드 (수정/보기 모드)
if (id && (isViewMode || isEditMode)) {
const result = await getPricingDetail(id);
if (result.success && result.data) {
setPricing(result.data);
setFormData({
itemType: result.data.itemType,
category: result.data.category,
itemName: result.data.itemName,
spec: result.data.spec,
unit: result.data.unit,
division: result.data.division,
vendor: result.data.vendor,
purchasePrice: result.data.purchasePrice,
marginRate: result.data.marginRate,
sellingPrice: result.data.sellingPrice,
status: result.data.status,
note: '',
});
} else {
toast.error(result.error || '데이터를 불러올 수 없습니다.');
router.push('/ko/juil/order/base-info/pricing');
}
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
loadData();
}, [id, mode, isViewMode, isEditMode, router]);
// 판매단가 자동 계산: 매입단가 * (1 + 마진율/100)
const calculatedSellingPrice = useMemo(() => {
const price = formData.purchasePrice * (1 + formData.marginRate / 100);
return Math.round(price);
}, [formData.purchasePrice, formData.marginRate]);
// 매입단가 변경
const handlePurchasePriceChange = useCallback((value: string) => {
const numValue = parseFloat(value) || 0;
setFormData((prev) => ({
...prev,
purchasePrice: numValue,
sellingPrice: Math.round(numValue * (1 + prev.marginRate / 100)),
}));
}, []);
// 마진율 변경
const handleMarginRateChange = useCallback((value: string) => {
const numValue = parseFloat(value) || 0;
setFormData((prev) => ({
...prev,
marginRate: numValue,
sellingPrice: Math.round(prev.purchasePrice * (1 + numValue / 100)),
}));
}, []);
// 거래처 변경
const handleVendorChange = useCallback((value: string) => {
setFormData((prev) => ({ ...prev, vendor: value }));
}, []);
// 상태 변경
const handleStatusChange = useCallback((value: string) => {
setFormData((prev) => ({ ...prev, status: value as PricingStatus }));
}, []);
// 비고 변경
const handleNoteChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFormData((prev) => ({ ...prev, note: e.target.value }));
}, []);
// 저장
const handleSave = useCallback(async () => {
setIsLoading(true);
try {
if (isCreateMode) {
const result = await createPricing({
itemType: formData.itemType,
category: formData.category,
itemName: formData.itemName,
spec: formData.spec,
orderItems: [],
unit: formData.unit,
division: formData.division,
vendor: formData.vendor,
purchasePrice: formData.purchasePrice,
marginRate: formData.marginRate,
sellingPrice: calculatedSellingPrice,
status: formData.status,
});
if (result.success) {
toast.success('단가가 등록되었습니다.');
router.push('/ko/juil/order/base-info/pricing');
} else {
toast.error(result.error || '등록에 실패했습니다.');
}
} else if (isEditMode && id) {
const result = await updatePricing(id, {
vendor: formData.vendor,
purchasePrice: formData.purchasePrice,
marginRate: formData.marginRate,
sellingPrice: calculatedSellingPrice,
status: formData.status,
});
if (result.success) {
toast.success('단가가 수정되었습니다.');
router.push(`/ko/juil/order/base-info/pricing/${id}`);
} else {
toast.error(result.error || '수정에 실패했습니다.');
}
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
}
}, [isCreateMode, isEditMode, id, formData, calculatedSellingPrice, router]);
// 삭제
const handleDelete = useCallback(async () => {
if (!id) return;
setIsLoading(true);
try {
const result = await deletePricing(id);
if (result.success) {
toast.success('단가가 삭제되었습니다.');
router.push('/ko/juil/order/base-info/pricing');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
}
}, [id, router]);
// 수정 페이지로 이동
const handleEdit = useCallback(() => {
if (id) {
router.push(`/ko/juil/order/base-info/pricing/${id}/edit`);
}
}, [id, router]);
// 취소
const handleCancel = useCallback(() => {
if (isCreateMode) {
router.push('/ko/juil/order/base-info/pricing');
} else if (isEditMode && id) {
router.push(`/ko/juil/order/base-info/pricing/${id}`);
}
}, [isCreateMode, isEditMode, id, router]);
// 목록으로 이동
const handleBack = useCallback(() => {
router.push('/ko/juil/order/base-info/pricing');
}, [router]);
// 숫자 포맷
const formatNumber = (num: number) => num.toLocaleString('ko-KR');
// 페이지 제목
const pageTitle = isCreateMode ? '단가 등록' : isEditMode ? '단가 수정' : '단가 상세';
const pageDescription = '단가 정보를 등록하고 관리합니다';
// 동적 항목 (무게, 두께 등) 가져오기
const dynamicOrderItems = pricing?.orderItems || [];
return (
<PageLayout>
<PageHeader
title={pageTitle}
description={pageDescription}
icon={DollarSign}
actions={
<div className="flex items-center gap-2">
{isViewMode && (
<>
<Button variant="outline" onClick={handleBack}>
</Button>
<Button variant="outline" onClick={() => setDeleteDialogOpen(true)}>
</Button>
<Button onClick={handleEdit}></Button>
</>
)}
{isEditMode && (
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading ? '저장 중...' : '저장'}
</Button>
</>
)}
{isCreateMode && (
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading ? '등록 중...' : '등록'}
</Button>
</>
)}
</div>
}
/>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 단가번호 / 품목유형 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
{isCreateMode ? (
<Input value="자동생성" disabled />
) : (
<Input value={pricing?.pricingNumber || ''} disabled />
)}
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.itemType} disabled />
</div>
</div>
{/* 카테고리명 / 품목명 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.category} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.itemName} disabled />
</div>
</div>
{/* 규격 / 동적항목 (무게 등) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.spec} disabled />
</div>
{dynamicOrderItems.length > 0 ? (
<div className="space-y-2">
<Label>{dynamicOrderItems[0]?.name || '무게'}</Label>
<Input value={dynamicOrderItems[0]?.value || '-'} disabled />
</div>
) : (
<div className="space-y-2">
<Label></Label>
<Input value="-" disabled />
</div>
)}
</div>
{/* 단위 / 구분 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input value={formData.unit} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.division} disabled />
</div>
</div>
{/* 거래처 / 매입단가 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={formData.vendor} disabled />
) : (
<Select value={formData.vendor} onValueChange={handleVendorChange}>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.name}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={formatNumber(formData.purchasePrice)} disabled />
) : (
<Input
type="number"
value={formData.purchasePrice}
onChange={(e) => handlePurchasePriceChange(e.target.value)}
placeholder="매입단가 입력"
/>
)}
</div>
</div>
{/* 마진율 / 판매단가 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label> (%)</Label>
{isViewMode ? (
<Input value={`${formData.marginRate}%`} disabled />
) : (
<Input
type="number"
step="0.1"
value={formData.marginRate}
onChange={(e) => handleMarginRateChange(e.target.value)}
placeholder="마진율 입력"
/>
)}
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formatNumber(isViewMode ? formData.sellingPrice : calculatedSellingPrice)}
disabled
className="bg-muted"
/>
{!isViewMode && (
<p className="text-xs text-muted-foreground">
× (1 + ) =
</p>
)}
</div>
</div>
{/* 상태 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Input value={PRICING_STATUS_LABELS[formData.status]} disabled />
) : (
<Select value={formData.status} onValueChange={handleStatusChange}>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="in_use"></SelectItem>
<SelectItem value="stopped"></SelectItem>
</SelectContent>
</Select>
)}
</div>
<div />
</div>
{/* 비고 */}
<div className="space-y-2">
<Label></Label>
{isViewMode ? (
<Textarea value={formData.note || '-'} disabled rows={3} />
) : (
<Textarea
value={formData.note}
onChange={handleNoteChange}
placeholder="비고 입력"
rows={3}
/>
)}
</div>
</CardContent>
</Card>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -0,0 +1,638 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { DollarSign, Package, CheckCircle, AlertCircle, Pencil, Trash2, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow, TableHead } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { IntegratedListTemplateV2, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Pricing, PricingStats, OrderItem } from './types';
import {
ITEM_TYPE_OPTIONS,
CATEGORY_OPTIONS,
SPEC_OPTIONS,
DIVISION_OPTIONS,
STATUS_OPTIONS,
SORT_OPTIONS,
PRICING_STATUS_LABELS,
PRICING_STATUS_STYLES,
} from './types';
import {
getPricingList,
getPricingStats,
deletePricing,
deletePricings,
} from './actions';
interface PricingListClientProps {
initialData?: Pricing[];
initialStats?: PricingStats;
}
export default function PricingListClient({
initialData = [],
initialStats,
}: PricingListClientProps) {
const router = useRouter();
// 상태
const [pricingList, setPricingList] = useState<Pricing[]>(initialData);
const [stats, setStats] = useState<PricingStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [itemTypeFilter, setItemTypeFilter] = useState<string>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [specFilter, setSpecFilter] = useState<string>('all');
const [divisionFilter, setDivisionFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_use' | 'not_registered'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getPricingList({
startDate: startDate || undefined,
endDate: endDate || undefined,
itemType: itemTypeFilter,
category: categoryFilter,
spec: specFilter,
division: divisionFilter,
status: statusFilter,
sort: sortBy,
search: searchValue,
}),
getPricingStats(),
]);
if (listResult.success && listResult.data) {
setPricingList(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate, itemTypeFilter, categoryFilter, specFilter, divisionFilter, statusFilter, sortBy, searchValue]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 동적 항목명 컬럼 추출
const dynamicOrderItemColumns = useMemo(() => {
const columnSet = new Set<string>();
pricingList.forEach((pricing) => {
pricing.orderItems.forEach((item) => {
columnSet.add(item.name);
});
});
return Array.from(columnSet);
}, [pricingList]);
// 필터링된 데이터
const filteredPricing = useMemo(() => {
return pricingList.filter((pricing) => {
// 상태 탭 필터
if (activeStatTab === 'in_use' && pricing.status !== 'in_use') return false;
if (activeStatTab === 'not_registered' && pricing.status !== 'not_registered') return false;
// 품목유형 필터
if (itemTypeFilter !== 'all') {
const typeMap: Record<string, string> = {
box: '박스',
parts: '부속',
consumables: '소모품',
utility: '공과',
};
if (pricing.itemType !== typeMap[itemTypeFilter]) return false;
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
pricing.pricingNumber.toLowerCase().includes(search) ||
pricing.itemName.toLowerCase().includes(search) ||
pricing.category.toLowerCase().includes(search) ||
pricing.vendor.toLowerCase().includes(search)
);
}
return true;
});
}, [pricingList, activeStatTab, itemTypeFilter, searchValue]);
// 정렬
const sortedPricing = useMemo(() => {
const sorted = [...filteredPricing];
if (sortBy === 'oldest') {
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
} else if (sortBy === 'price_high') {
sorted.sort((a, b) => b.sellingPrice - a.sellingPrice);
} else if (sortBy === 'price_low') {
sorted.sort((a, b) => a.sellingPrice - b.sellingPrice);
} else {
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
return sorted;
}, [filteredPricing, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedPricing.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedPricing.slice(start, start + itemsPerPage);
}, [sortedPricing, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((p) => p.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(pricing: Pricing) => {
router.push(`/ko/juil/order/base-info/pricing/${pricing.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, pricingId: string) => {
e.stopPropagation();
router.push(`/ko/juil/order/base-info/pricing/${pricingId}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, pricingId: string) => {
e.stopPropagation();
setDeleteTargetId(pricingId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deletePricing(deleteTargetId);
if (result.success) {
toast.success('단가가 삭제되었습니다.');
setPricingList((prev) => prev.filter((p) => p.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deletePricings(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
const handleRegister = useCallback(() => {
router.push('/ko/juil/order/base-info/pricing/new');
}, [router]);
// 숫자 포맷
const formatNumber = (num: number) => {
return num.toLocaleString('ko-KR');
};
// 동적 항목값 가져오기
const getOrderItemValue = (orderItems: OrderItem[], columnName: string): string => {
const item = orderItems.find((oi) => oi.name === columnName);
return item?.value || '-';
};
// 커스텀 테이블 헤더 렌더링
const renderCustomTableHeader = useCallback(() => {
return (
<>
<TableHead className="w-[40px] text-center">
<Checkbox
checked={selectedItems.size === paginatedData.length && paginatedData.length > 0}
onCheckedChange={handleToggleSelectAll}
/>
</TableHead>
<TableHead className="w-[50px] text-center"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
{/* 동적 항목명 컬럼 */}
{dynamicOrderItemColumns.map((colName) => (
<TableHead key={colName} className="w-[80px]">
{colName}
</TableHead>
))}
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[70px] text-center"></TableHead>
{selectedItems.size > 0 && (
<TableHead className="w-[100px] text-center"></TableHead>
)}
</>
);
}, [selectedItems.size, paginatedData.length, handleToggleSelectAll, dynamicOrderItemColumns]);
// 테이블 행 렌더링
const renderTableRow = useCallback(
(pricing: Pricing, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(pricing.id);
return (
<TableRow
key={pricing.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(pricing)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(pricing.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{pricing.pricingNumber}</TableCell>
<TableCell>{pricing.itemType}</TableCell>
<TableCell>{pricing.category}</TableCell>
<TableCell>{pricing.itemName}</TableCell>
<TableCell>{pricing.spec}</TableCell>
{/* 동적 항목명 값 */}
{dynamicOrderItemColumns.map((colName) => (
<TableCell key={colName}>
{getOrderItemValue(pricing.orderItems, colName)}
</TableCell>
))}
<TableCell>{pricing.unit}</TableCell>
<TableCell>{pricing.division}</TableCell>
<TableCell>{pricing.vendor}</TableCell>
<TableCell className="text-right">{formatNumber(pricing.purchasePrice)}</TableCell>
<TableCell className="text-right">{pricing.marginRate}%</TableCell>
<TableCell className="text-right">{formatNumber(pricing.sellingPrice)}</TableCell>
<TableCell className="text-center">
<Badge className={PRICING_STATUS_STYLES[pricing.status]}>
{PRICING_STATUS_LABELS[pricing.status]}
</Badge>
</TableCell>
{selectedItems.size > 0 && (
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, pricing.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, pricing.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
)}
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick, dynamicOrderItemColumns]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(pricing: Pricing, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={pricing.itemName}
subtitle={pricing.pricingNumber}
badge={PRICING_STATUS_LABELS[pricing.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(pricing)}
details={[
{ label: '품목유형', value: pricing.itemType },
{ label: '카테고리', value: pricing.category },
{ label: '판매단가', value: formatNumber(pricing.sellingPrice) },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (달력 + 등록버튼)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
<Button onClick={handleRegister}>
<Plus className="mr-2 h-4 w-4" />
</Button>
}
/>
);
// Stats 카드 데이터
const statsCardsData: StatCard[] = [
{
label: '전체 단가',
value: stats?.total ?? 0,
icon: Package,
iconColor: 'text-gray-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '사용 단가',
value: stats?.inUse ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('in_use'),
isActive: activeStatTab === 'in_use',
},
{
label: '미등록 단가',
value: stats?.notRegistered ?? 0,
icon: AlertCircle,
iconColor: 'text-gray-400',
onClick: () => setActiveStatTab('not_registered'),
isActive: activeStatTab === 'not_registered',
},
];
// 테이블 헤더 액션 (필터 6개)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedPricing.length}
</span>
{/* 품목유형 필터 */}
<Select value={itemTypeFilter} onValueChange={setItemTypeFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="품목유형" />
</SelectTrigger>
<SelectContent>
{ITEM_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 카테고리 필터 */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="카테고리" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 규격 필터 */}
<Select value={specFilter} onValueChange={setSpecFilter}>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="규격" />
</SelectTrigger>
<SelectContent>
{SPEC_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 구분 필터 */}
<Select value={divisionFilter} onValueChange={setDivisionFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{DIVISION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[90px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
// 동적 컬럼으로 인해 tableColumns를 사용하지 않고 커스텀 헤더 사용
const emptyTableColumns: { key: string; label: string; className?: string }[] = [];
return (
<>
<IntegratedListTemplateV2
title="단가관리"
description="단가를 등록하고 관리합니다"
icon={DollarSign}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="단가번호, 품목명, 카테고리, 거래처 검색"
tableColumns={emptyTableColumns}
renderCustomTableHeader={renderCustomTableHeader}
data={paginatedData}
allData={sortedPricing}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedPricing.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
isLoading={isLoading}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,388 @@
'use server';
import type { Pricing, PricingStats } from './types';
// ===== 목데이터 =====
const mockPricingList: Pricing[] = [
{
id: '1',
pricingNumber: 'PRC-2026-001',
itemType: '박스',
category: '슬라이드 OPEN 사이즈',
itemName: '슬라이드 도어 세트',
spec: '1200x2400',
orderItems: [
{ id: 'oi1', name: '무게', value: '400KG' },
{ id: 'oi2', name: '두께', value: '50mm' },
],
unit: 'SET',
division: '일반',
vendor: '(주)슬라이드텍',
purchasePrice: 850000,
marginRate: 15,
sellingPrice: 977500,
status: 'in_use',
createdAt: '2026-01-02',
},
{
id: '2',
pricingNumber: 'PRC-2026-002',
itemType: '부속',
category: '모터',
itemName: '서보모터 750W',
spec: 'AC220V',
orderItems: [
{ id: 'oi3', name: '무게', value: '12KG' },
],
unit: 'EA',
division: '일반',
vendor: '삼성전기',
purchasePrice: 320000,
marginRate: 20,
sellingPrice: 384000,
status: 'in_use',
createdAt: '2026-01-02',
},
{
id: '3',
pricingNumber: 'PRC-2026-003',
itemType: '소모품',
category: '공정자재',
itemName: '용접봉 E7016',
spec: '4.0mm x 350mm',
orderItems: [
{ id: 'oi4', name: '무게', value: '5KG' },
],
unit: 'BOX',
division: '일반',
vendor: '현대용접산업',
purchasePrice: 45000,
marginRate: 25,
sellingPrice: 56250,
status: 'in_use',
createdAt: '2026-01-03',
},
{
id: '4',
pricingNumber: 'PRC-2026-004',
itemType: '공과',
category: '철물',
itemName: '앵커볼트 세트',
spec: 'M12 x 100',
orderItems: [
{ id: 'oi5', name: '무게', value: '500G' },
],
unit: 'SET',
division: '특수',
vendor: '철강볼트',
purchasePrice: 12000,
marginRate: 30,
sellingPrice: 15600,
status: 'in_use',
createdAt: '2026-01-03',
},
{
id: '5',
pricingNumber: 'PRC-2026-005',
itemType: '박스',
category: '슬라이드 OPEN 사이즈',
itemName: '자동문 프레임',
spec: '900x2100',
orderItems: [
{ id: 'oi6', name: '무게', value: '280KG' },
{ id: 'oi7', name: '두께', value: '40mm' },
],
unit: 'SET',
division: '일반',
vendor: '(주)슬라이드텍',
purchasePrice: 650000,
marginRate: 18,
sellingPrice: 767000,
status: 'not_registered',
createdAt: '2026-01-04',
},
{
id: '6',
pricingNumber: 'PRC-2026-006',
itemType: '부속',
category: '모터',
itemName: '기어드모터 1.5KW',
spec: 'AC380V',
orderItems: [
{ id: 'oi8', name: '무게', value: '25KG' },
],
unit: 'EA',
division: '특수',
vendor: '삼성전기',
purchasePrice: 580000,
marginRate: 22,
sellingPrice: 707600,
status: 'in_use',
createdAt: '2026-01-04',
},
{
id: '7',
pricingNumber: 'PRC-2026-007',
itemType: '소모품',
category: '공정자재',
itemName: '절삭유 WS-300',
spec: '20L',
orderItems: [],
unit: 'CAN',
division: '일반',
vendor: '한국윤활유',
purchasePrice: 85000,
marginRate: 15,
sellingPrice: 97750,
status: 'not_registered',
createdAt: '2026-01-05',
},
{
id: '8',
pricingNumber: 'PRC-2026-008',
itemType: '공과',
category: '철물',
itemName: '스테인레스 볼트',
spec: 'M10 x 50',
orderItems: [
{ id: 'oi9', name: '무게', value: '200G' },
],
unit: 'BOX',
division: '일반',
vendor: '철강볼트',
purchasePrice: 35000,
marginRate: 28,
sellingPrice: 44800,
status: 'in_use',
createdAt: '2026-01-05',
},
];
// ===== 단가 목록 조회 =====
export async function getPricingList(params?: {
startDate?: string;
endDate?: string;
itemType?: string;
category?: string;
spec?: string;
division?: string;
status?: string;
sort?: string;
search?: string;
}): Promise<{
success: boolean;
data?: { items: Pricing[]; total: number };
error?: string;
}> {
try {
let filtered = [...mockPricingList];
// 품목유형 필터
if (params?.itemType && params.itemType !== 'all') {
const typeMap: Record<string, string> = {
box: '박스',
parts: '부속',
consumables: '소모품',
utility: '공과',
};
filtered = filtered.filter(p => p.itemType === typeMap[params.itemType!]);
}
// 카테고리 필터
if (params?.category && params.category !== 'all') {
const categoryMap: Record<string, string> = {
slide_open: '슬라이드 OPEN 사이즈',
motor: '모터',
process_material: '공정자재',
hardware: '철물',
};
filtered = filtered.filter(p => p.category === categoryMap[params.category!]);
}
// 구분 필터
if (params?.division && params.division !== 'all') {
const divisionMap: Record<string, string> = {
general: '일반',
special: '특수',
};
filtered = filtered.filter(p => p.division === divisionMap[params.division!]);
}
// 검색 필터
if (params?.search) {
const search = params.search.toLowerCase();
filtered = filtered.filter(p =>
p.pricingNumber.toLowerCase().includes(search) ||
p.itemName.toLowerCase().includes(search) ||
p.category.toLowerCase().includes(search) ||
p.vendor.toLowerCase().includes(search)
);
}
// 정렬
if (params?.sort === 'oldest') {
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
} else if (params?.sort === 'price_high') {
filtered.sort((a, b) => b.sellingPrice - a.sellingPrice);
} else if (params?.sort === 'price_low') {
filtered.sort((a, b) => a.sellingPrice - b.sellingPrice);
} else {
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
return {
success: true,
data: {
items: filtered,
total: filtered.length,
},
};
} catch (error) {
console.error('[getPricingList] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 통계 조회 =====
export async function getPricingStats(): Promise<{
success: boolean;
data?: PricingStats;
error?: string;
}> {
try {
const stats: PricingStats = {
total: mockPricingList.length,
inUse: mockPricingList.filter(p => p.status === 'in_use').length,
notRegistered: mockPricingList.filter(p => p.status === 'not_registered').length,
};
return { success: true, data: stats };
} catch (error) {
console.error('[getPricingStats] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 단일 삭제 =====
export async function deletePricing(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
// 목데이터에서는 실제 삭제하지 않음
const index = mockPricingList.findIndex(p => p.id === id);
if (index === -1) {
return { success: false, error: '단가를 찾을 수 없습니다.' };
}
return { success: true };
} catch (error) {
console.error('[deletePricing] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 일괄 삭제 =====
export async function deletePricings(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
try {
return { success: true, deletedCount: ids.length };
} catch (error) {
console.error('[deletePricings] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 단가 상세 조회 =====
export async function getPricingDetail(id: string): Promise<{
success: boolean;
data?: Pricing;
error?: string;
}> {
try {
const pricing = mockPricingList.find(p => p.id === id);
if (!pricing) {
return { success: false, error: '단가를 찾을 수 없습니다.' };
}
return { success: true, data: pricing };
} catch (error) {
console.error('[getPricingDetail] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 단가 생성 =====
export async function createPricing(data: Omit<Pricing, 'id' | 'pricingNumber' | 'createdAt'>): Promise<{
success: boolean;
data?: Pricing;
error?: string;
}> {
try {
const newId = String(mockPricingList.length + 1);
const newPricingNumber = `PRC-2026-${String(mockPricingList.length + 1).padStart(3, '0')}`;
const newPricing: Pricing = {
...data,
id: newId,
pricingNumber: newPricingNumber,
createdAt: new Date().toISOString().split('T')[0],
};
// 목데이터에는 추가하지 않음 (실제 API 연동 시 DB에 저장)
return { success: true, data: newPricing };
} catch (error) {
console.error('[createPricing] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 단가 수정 =====
export async function updatePricing(id: string, data: Partial<Pricing>): Promise<{
success: boolean;
data?: Pricing;
error?: string;
}> {
try {
const index = mockPricingList.findIndex(p => p.id === id);
if (index === -1) {
return { success: false, error: '단가를 찾을 수 없습니다.' };
}
const updatedPricing: Pricing = {
...mockPricingList[index],
...data,
updatedAt: new Date().toISOString().split('T')[0],
};
// 목데이터에는 수정하지 않음 (실제 API 연동 시 DB에 업데이트)
return { success: true, data: updatedPricing };
} catch (error) {
console.error('[updatePricing] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 거래처 목록 조회 (발주처) =====
export async function getVendorList(): Promise<{
success: boolean;
data?: { id: string; name: string }[];
error?: string;
}> {
try {
// 목데이터에서 거래처 추출
const vendors = [
{ id: '1', name: '(주)슬라이드텍' },
{ id: '2', name: '삼성전기' },
{ id: '3', name: '현대용접산업' },
{ id: '4', name: '철강볼트' },
{ id: '5', name: '한국윤활유' },
];
return { success: true, data: vendors };
} catch (error) {
console.error('[getVendorList] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -0,0 +1,3 @@
export { default as PricingListClient } from './PricingListClient';
export * from './types';
export * from './actions';

View File

@@ -0,0 +1,103 @@
/**
* 단가관리 타입 정의
*/
// 단가 상태
export type PricingStatus = 'in_use' | 'stopped' | 'not_registered';
// 발주 항목 (동적으로 추가되는 항목)
export interface OrderItem {
id: string;
name: string; // 항목명 (예: 무게)
value: string; // 구분 정보 (예: 400KG)
}
// 단가 데이터
export interface Pricing {
id: string;
pricingNumber: string; // 단가번호
itemType: string; // 품목유형 (박스, 부속, 소모품, 공과)
category: string; // 카테고리
itemName: string; // 품목명
spec: string; // 규격
orderItems: OrderItem[]; // 발주항목 (동적 컬럼)
unit: string; // 단위
division: string; // 구분
vendor: string; // 거래처
purchasePrice: number; // 매입단가
marginRate: number; // 마진율 (%)
sellingPrice: number; // 판매단가
status: PricingStatus; // 상태
createdAt: string; // 생성일
updatedAt?: string; // 수정일
}
// 통계 데이터
export interface PricingStats {
total: number; // 전체 단가
inUse: number; // 사용 단가
notRegistered: number; // 미등록 단가
}
// ===== 필터 옵션 =====
// 품목유형 옵션
export const ITEM_TYPE_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'product', label: '제품' },
{ value: 'parts', label: '부품' },
{ value: 'consumables', label: '소모품' },
{ value: 'utility', label: '공과' },
] as const;
// 카테고리 옵션 (기본 + 동적 목록)
export const CATEGORY_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'basic', label: '기본' },
{ value: 'slide_open', label: '슬라이드 OPEN 사이즈' },
{ value: 'motor', label: '모터' },
{ value: 'process_material', label: '공정자재' },
{ value: 'hardware', label: '철물' },
] as const;
// 규격 옵션
export const SPEC_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'certified', label: '인정' },
{ value: 'non_certified', label: '비인정' },
] as const;
// 구분 옵션
export const DIVISION_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'kyungdong', label: '경동발주' },
{ value: 'raw_material', label: '원자재발주' },
{ value: 'outsourcing', label: '외주발주' },
] as const;
// 상태 옵션
export const STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'in_use', label: '사용' },
{ value: 'stopped', label: '중지' },
{ value: 'not_registered', label: '미등록' },
] as const;
// 정렬 옵션
export const SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
] as const;
// ===== 상태 스타일 =====
export const PRICING_STATUS_LABELS: Record<PricingStatus, string> = {
in_use: '사용',
stopped: '중지',
not_registered: '미등록',
};
export const PRICING_STATUS_STYLES: Record<PricingStatus, string> = {
in_use: 'bg-green-100 text-green-800',
stopped: 'bg-yellow-100 text-yellow-800',
not_registered: 'bg-gray-100 text-gray-800',
};

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useCallback, useMemo, useRef } from 'react';
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic } from 'lucide-react';
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -52,6 +52,31 @@ const MOCK_ATTENDEES = [
{ value: 'lee', label: '이영희' },
];
// 목업 문서 목록 (상세 모드에서 다운로드 버튼 테스트용)
const MOCK_DOCUMENTS: BriefingDocument[] = [
{
id: '1',
fileName: '현장설명회_안내문.pdf',
fileUrl: '#',
fileSize: 1536000, // 1.5MB
uploadedAt: '2024-12-20T09:00:00',
},
{
id: '2',
fileName: '입찰참가신청서.docx',
fileUrl: '#',
fileSize: 512000, // 500KB
uploadedAt: '2024-12-20T09:30:00',
},
{
id: '3',
fileName: '현장배치도.pdf',
fileUrl: '#',
fileSize: 3145728, // 3MB
uploadedAt: '2024-12-21T14:00:00',
},
];
interface SiteBriefingFormProps {
mode: 'view' | 'edit' | 'new';
briefingId?: string;
@@ -82,6 +107,16 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
// 상세/수정 모드에서 목데이터 초기화
useEffect(() => {
if (initialData && formData.documents.length === 0) {
setFormData((prev) => ({
...prev,
documents: MOCK_DOCUMENTS,
}));
}
}, [initialData]);
// 필드 변경 핸들러
const handleChange = useCallback((field: keyof SiteBriefingFormData, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
@@ -545,7 +580,24 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
>
<FileText className="w-4 h-4 text-primary" />
<span className="text-sm">{doc.fileName}</span>
{!isViewMode && (
{isViewMode ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
const link = document.createElement('a');
link.href = doc.fileUrl;
link.download = doc.fileName;
link.click();
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
}}
>
<Download className="h-3 w-3 mr-1" />
</Button>
) : (
<Button
type="button"
variant="ghost"

View File

@@ -13,6 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
@@ -83,17 +84,15 @@ const STATUS_LABELS: Record<string, string> = {
absent: '불참',
};
// 목업 거래처 목록
const MOCK_PARTNERS = [
{ value: 'all', label: '전체' },
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: '1', label: '대한건설' },
{ value: '2', label: '삼성시공' },
{ value: '3', label: 'LG건설' },
];
// 목업 참석자 목록
const MOCK_ATTENDEES = [
{ value: 'all', label: '전체' },
// 목업 참석자 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_ATTENDEES: MultiSelectOption[] = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
@@ -111,9 +110,9 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
const [statsData, setStatsData] = useState({ total: 0, scheduled: 0, attended: 0 });
const [activeStatTab, setActiveStatTab] = useState<'all' | 'scheduled' | 'attended'>('all');
const [searchValue, setSearchValue] = useState('');
const [partnerFilter, setPartnerFilter] = useState<string>('all');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [typeFilter, setTypeFilter] = useState<string>('all');
const [attendeeFilter, setAttendeeFilter] = useState<string>('all');
const [attendeeFilters, setAttendeeFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
@@ -167,14 +166,20 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
if (activeStatTab === 'scheduled' && briefing.status !== 'scheduled') return false;
if (activeStatTab === 'attended' && briefing.status === 'scheduled') return false;
// 거래처 필터
if (partnerFilter !== 'all' && briefing.partnerId !== partnerFilter) return false;
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(briefing.partnerId)) return false;
}
// 구분 필터 (목업에서는 모두 통과)
// if (typeFilter !== 'all') return false;
// 참석자 필터 (목업에서는 모두 통과)
// if (attendeeFilter !== 'all') return false;
// 참석자 필터 (다중선택 - 빈 배열 = 전체)
if (attendeeFilters.length > 0) {
// 목업 데이터에 attendeeId가 없으므로 임시로 'hong'으로 처리
const attendeeId = 'hong';
if (!attendeeFilters.includes(attendeeId)) return false;
}
// 상태 필터
if (statusFilter !== 'all' && briefing.status !== statusFilter) return false;
@@ -190,7 +195,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
}
return true;
});
}, [briefings, activeStatTab, partnerFilter, statusFilter, searchValue]);
}, [briefings, activeStatTab, partnerFilters, attendeeFilters, statusFilter, searchValue]);
// 정렬
const sortedBriefings = useMemo(() => {
@@ -477,19 +482,15 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
{sortedBriefings.length}
</span>
{/* 거래처 필터 */}
<Select value={partnerFilter} onValueChange={setPartnerFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="거래처" />
</SelectTrigger>
<SelectContent>
{MOCK_PARTNERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 구분 필터 */}
<Select value={typeFilter} onValueChange={setTypeFilter}>
@@ -505,19 +506,15 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
</SelectContent>
</Select>
{/* 참석자 필터 */}
<Select value={attendeeFilter} onValueChange={setAttendeeFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="참석자" />
</SelectTrigger>
<SelectContent>
{MOCK_ATTENDEES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 참석자 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_ATTENDEES}
value={attendeeFilters}
onChange={setAttendeeFilters}
placeholder="참석자"
searchPlaceholder="참석자 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>

View File

@@ -0,0 +1,485 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Building2, Upload, Mic, X, FileText, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import type { Site } from './types';
import { SITE_STATUS_OPTIONS } from './types';
// 도면 파일 타입
interface DrawingDocument {
id: string;
fileName: string;
fileUrl: string;
fileSize: number;
uploadedAt: string;
}
interface SiteDetailFormProps {
site?: Site;
mode?: 'view' | 'edit';
}
export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormProps) {
const router = useRouter();
const isEditMode = mode === 'edit';
// 파일 업로드 ref
const drawingInputRef = useRef<HTMLInputElement>(null);
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
// 목데이터: 기존 도면 파일
const MOCK_DRAWINGS: DrawingDocument[] = site ? [
{
id: '1',
fileName: '현장배치도_v2.pdf',
fileUrl: '#',
fileSize: 2458624, // 2.4MB
uploadedAt: '2024-12-15T10:30:00',
},
{
id: '2',
fileName: '구조도면_최종.dwg',
fileUrl: '#',
fileSize: 5242880, // 5MB
uploadedAt: '2024-12-16T14:20:00',
},
{
id: '3',
fileName: '현장사진_001.jpg',
fileUrl: '#',
fileSize: 1048576, // 1MB
uploadedAt: '2024-12-18T09:15:00',
},
] : [];
// 도면 파일 목록
const [drawings, setDrawings] = useState<DrawingDocument[]>(MOCK_DRAWINGS);
// 폼 상태
const [formData, setFormData] = useState({
siteCode: site?.siteCode || '',
partnerName: site?.partnerName || '',
siteName: site?.siteName || '',
status: site?.status || 'active',
zonecode: '',
address: site?.address || '',
addressDetail: '',
longitude: '',
latitude: '',
memo: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Daum 우편번호 검색
const { openPostcode, isScriptLoaded } = useDaumPostcode({
onComplete: (result) => {
setFormData((prev) => ({
...prev,
zonecode: result.zonecode,
address: result.address,
}));
},
});
// 입력 핸들러
const handleInputChange = useCallback(
(field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
},
[]
);
const handleSelectChange = useCallback((field: string) => (value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 수정 버튼 클릭
const handleEditClick = useCallback(() => {
if (site?.id) {
router.push(`/ko/juil/order/site-management/${site.id}/edit`);
}
}, [router, site?.id]);
// 저장
const handleSubmit = useCallback(async () => {
if (!formData.siteName.trim()) {
toast.error('현장명을 입력해주세요.');
return;
}
setIsSubmitting(true);
try {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('저장되었습니다.');
router.push('/ko/juil/order/site-management');
} catch {
toast.error('저장에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
}, [formData, router]);
// 취소
const handleCancel = useCallback(() => {
router.back();
}, [router]);
// 도면 파일 업로드 핸들러
const handleDrawingUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 파일 크기 검증 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error('파일 크기는 10MB 이하여야 합니다.');
return;
}
const doc: DrawingDocument = {
id: String(Date.now()),
fileName: file.name,
fileUrl: URL.createObjectURL(file),
fileSize: file.size,
uploadedAt: new Date().toISOString(),
};
setDrawings((prev) => [...prev, doc]);
if (drawingInputRef.current) {
drawingInputRef.current.value = '';
}
}, []);
// 도면 파일 삭제 핸들러
const handleDrawingRemove = useCallback((docId: string) => {
setDrawings((prev) => prev.filter((d) => d.id !== docId));
}, []);
// 드래그 핸들러
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (isEditMode) {
setIsDragging(true);
}
}, [isEditMode]);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (!isEditMode) return;
const files = Array.from(e.dataTransfer.files);
files.forEach((file) => {
// 파일 크기 검증 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
return;
}
const doc: DrawingDocument = {
id: String(Date.now() + Math.random()),
fileName: file.name,
fileUrl: URL.createObjectURL(file),
fileSize: file.size,
uploadedAt: new Date().toISOString(),
};
setDrawings((prev) => [...prev, doc]);
});
}, [isEditMode]);
return (
<PageLayout>
<PageHeader
title={isEditMode ? '현장 수정' : '현장 상세'}
description="현장을 등록하고 관리합니다"
icon={Building2}
actions={
!isEditMode ? (
<>
<Button variant="outline" onClick={() => router.push('/ko/juil/order/site-management')}>
</Button>
<Button onClick={handleEditClick}></Button>
</>
) : (
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? '저장 중...' : '저장'}
</Button>
</>
)
}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> *</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 현장번호 */}
<div className="space-y-2">
<Label htmlFor="siteCode"></Label>
<Input
id="siteCode"
value={formData.siteCode}
onChange={handleInputChange('siteCode')}
placeholder="123-12-12345"
disabled={!isEditMode}
/>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="partnerName"></Label>
<Input
id="partnerName"
value={formData.partnerName}
onChange={handleInputChange('partnerName')}
placeholder="거래처명"
disabled={!isEditMode}
/>
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label htmlFor="siteName"></Label>
<Input
id="siteName"
value={formData.siteName}
onChange={handleInputChange('siteName')}
placeholder="현장명"
disabled={!isEditMode}
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={handleSelectChange('status')}
disabled={!isEditMode}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{SITE_STATUS_OPTIONS.filter((opt) => opt.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 위치 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> *</CardTitle>
<p className="text-sm text-muted-foreground">
</p>
</CardHeader>
<CardContent className="space-y-4">
{/* 주소 */}
<div className="space-y-2">
<Label></Label>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={openPostcode}
disabled={!isScriptLoaded || !isEditMode}
className="shrink-0"
>
</Button>
<Input
value={formData.address}
onChange={handleInputChange('address')}
placeholder="상세주소를 입력해주세요"
disabled={!isEditMode}
className="flex-1"
/>
</div>
</div>
{/* 경도/위도 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="longitude"></Label>
<Input
id="longitude"
value={formData.longitude}
onChange={handleInputChange('longitude')}
placeholder="경도를 입력해주세요"
disabled={!isEditMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="latitude"></Label>
<Input
id="latitude"
value={formData.latitude}
onChange={handleInputChange('latitude')}
placeholder="위도를 입력해주세요"
disabled={!isEditMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 도면 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<input
ref={drawingInputRef}
type="file"
onChange={handleDrawingUpload}
className="hidden"
/>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
!isEditMode
? 'bg-gray-50'
: isDragging
? 'border-primary bg-primary/5'
: 'hover:border-primary/50 cursor-pointer'
}`}
onClick={() => isEditMode && drawingInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className={`mx-auto h-8 w-8 mb-2 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`} />
<p className="text-sm text-muted-foreground">
{isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'}
</p>
</div>
{/* 업로드된 파일 목록 */}
{drawings.length > 0 && (
<div className="space-y-2">
{drawings.map((doc) => (
<div key={doc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-primary" />
<div>
<p className="text-sm font-medium">{doc.fileName}</p>
<p className="text-xs text-gray-500">
{(doc.fileSize / 1024).toFixed(1)} KB
</p>
</div>
</div>
{isEditMode ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleDrawingRemove(doc.id)}
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = doc.fileUrl;
link.download = doc.fileName;
link.click();
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
}}
>
<Download className="h-4 w-4 mr-1" />
</Button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 실측 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="memo"></Label>
<div className="relative">
<Textarea
id="memo"
value={formData.memo}
onChange={handleInputChange('memo')}
placeholder="메모를 입력하세요"
disabled={!isEditMode}
className="min-h-[120px]"
/>
{isEditMode && (
<Button
type="button"
variant="default"
size="sm"
className="absolute bottom-2 right-2"
>
<Mic className="h-4 w-4 mr-1" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,503 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Building2, HardHat, AlertCircle, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Site, SiteStats } from './types';
import {
SITE_STATUS_OPTIONS,
SITE_SORT_OPTIONS,
SITE_STATUS_STYLES,
SITE_STATUS_LABELS,
} from './types';
import { getSiteList, getSiteStats, deleteSite, deleteSites } from './actions';
// 테이블 컬럼 정의
// 순서: 체크박스, 번호, 현장번호, 거래처, 현장명, 위치, 상태, 작업
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'siteCode', label: '현장번호', className: 'w-[120px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'address', label: '위치', className: 'min-w-[200px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 목업 거래처 목록
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: '1', label: '회사명A' },
{ value: '2', label: '회사명B' },
{ value: '3', label: '회사명C' },
];
interface SiteManagementListClientProps {
initialData?: Site[];
initialStats?: SiteStats;
}
export default function SiteManagementListClient({
initialData = [],
initialStats,
}: SiteManagementListClientProps) {
const router = useRouter();
// 상태
const [sites, setSites] = useState<Site[]>(initialData);
const [stats, setStats] = useState<SiteStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'construction' | 'unregistered'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getSiteList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getSiteStats(),
]);
if (listResult.success && listResult.data) {
setSites(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredSites = useMemo(() => {
return sites.filter((site) => {
// 상태 탭 필터
if (activeStatTab === 'construction' && site.status !== 'active') return false;
if (activeStatTab === 'unregistered' && site.status !== 'unregistered') return false;
// 거래처 필터
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(site.partnerId)) return false;
}
// 상태 필터
if (statusFilter !== 'all' && site.status !== statusFilter) return false;
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
site.siteName.toLowerCase().includes(search) ||
site.siteCode.toLowerCase().includes(search) ||
site.partnerName.toLowerCase().includes(search) ||
site.address.toLowerCase().includes(search)
);
}
return true;
});
}, [sites, activeStatTab, partnerFilters, statusFilter, searchValue]);
// 정렬
const sortedSites = useMemo(() => {
const sorted = [...filteredSites];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'siteNameAsc':
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
break;
case 'siteNameDesc':
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
break;
}
return sorted;
}, [filteredSites, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedSites.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedSites.slice(start, start + itemsPerPage);
}, [sortedSites, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((s) => s.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(site: Site) => {
router.push(`/ko/juil/order/site-management/${site.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, siteId: string) => {
e.stopPropagation();
router.push(`/ko/juil/order/site-management/${siteId}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, siteId: string) => {
e.stopPropagation();
setDeleteTargetId(siteId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteSite(deleteTargetId);
if (result.success) {
toast.success('현장이 삭제되었습니다.');
setSites((prev) => prev.filter((s) => s.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteSites(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
// 테이블 행 렌더링
// 순서: 체크박스, 번호, 현장번호, 거래처, 현장명, 위치, 상태, 작업
const renderTableRow = useCallback(
(site: Site, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(site.id);
return (
<TableRow
key={site.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(site)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(site.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{site.siteCode}</TableCell>
<TableCell>{site.partnerName}</TableCell>
<TableCell>{site.siteName}</TableCell>
<TableCell>{site.address || '-'}</TableCell>
<TableCell className="text-center">
<span className={SITE_STATUS_STYLES[site.status]}>
{SITE_STATUS_LABELS[site.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, site.id)}
>
<Pencil className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(site: Site, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={site.siteName}
subtitle={site.siteCode}
badge={SITE_STATUS_LABELS[site.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(site)}
details={[
{ label: '거래처', value: site.partnerName },
{ label: '위치', value: site.address || '-' },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜 필터만)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// Stats 카드 데이터 (전체 현장, 시공 현장, 미등록 현장)
const statsCardsData: StatCard[] = [
{
label: '전체 현장',
value: stats?.total ?? 0,
icon: Building2,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '시공 현장',
value: stats?.construction ?? 0,
icon: HardHat,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('construction'),
isActive: activeStatTab === 'construction',
},
{
label: '미등록 현장',
value: stats?.unregistered ?? 0,
icon: AlertCircle,
iconColor: 'text-red-500',
onClick: () => setActiveStatTab('unregistered'),
isActive: activeStatTab === 'unregistered',
},
];
// 테이블 헤더 액션 (총 건수 + 필터들)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedSites.length}
</span>
{/* 거래처 필터 */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{SITE_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{SITE_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="현장관리"
description="현장을 관리합니다"
icon={Building2}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="현장번호, 거래처, 현장명, 위치 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedSites}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedSites.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,195 @@
'use server';
import type { Site, SiteStats } from './types';
// 목업 현장 데이터
const MOCK_SITES: Site[] = [
{
id: '1',
siteCode: '123123',
partnerId: '1',
partnerName: '회사명',
siteName: '현장명',
address: '-',
status: 'unregistered',
createdAt: '2025-09-01T00:00:00Z',
updatedAt: '2025-09-01T00:00:00Z',
},
{
id: '2',
siteCode: '123123',
partnerId: '1',
partnerName: '회사명',
siteName: '현장명',
address: '서울시 강남구 대현빌라 123길',
status: 'suspended',
createdAt: '2025-09-02T00:00:00Z',
updatedAt: '2025-09-02T00:00:00Z',
},
{
id: '3',
siteCode: '123123',
partnerId: '2',
partnerName: '회사명',
siteName: '현장명',
address: '서울시 강남구 대현빌라 123길',
status: 'active',
createdAt: '2025-09-03T00:00:00Z',
updatedAt: '2025-09-03T00:00:00Z',
},
{
id: '4',
siteCode: '123123',
partnerId: '1',
partnerName: '회사명',
siteName: '현장명',
address: '서울시 강남구 대현빌라 123길',
status: 'active',
createdAt: '2025-09-04T00:00:00Z',
updatedAt: '2025-09-04T00:00:00Z',
},
{
id: '5',
siteCode: '123123',
partnerId: '3',
partnerName: '회사명',
siteName: '현장명',
address: '서울시 강남구 대현빌라 123길',
status: 'active',
createdAt: '2025-09-05T00:00:00Z',
updatedAt: '2025-09-05T00:00:00Z',
},
{
id: '6',
siteCode: '123123',
partnerId: '1',
partnerName: '회사명',
siteName: '현장명',
address: '서울시 강남구 대현빌라 123길',
status: 'active',
createdAt: '2025-09-06T00:00:00Z',
updatedAt: '2025-09-06T00:00:00Z',
},
{
id: '7',
siteCode: '123123',
partnerId: '2',
partnerName: '회사명',
siteName: '현장명',
address: '서울시 강남구 대현빌라 123길',
status: 'pending',
createdAt: '2025-09-07T00:00:00Z',
updatedAt: '2025-09-07T00:00:00Z',
},
];
interface GetSiteListParams {
size?: number;
startDate?: string;
endDate?: string;
}
interface GetSiteListResult {
success: boolean;
data?: {
items: Site[];
totalCount: number;
};
error?: string;
}
// 현장 목록 조회
export async function getSiteList(params: GetSiteListParams = {}): Promise<GetSiteListResult> {
try {
// TODO: API 연동 시 실제 API 호출로 변경
await new Promise((resolve) => setTimeout(resolve, 500));
let filteredSites = [...MOCK_SITES];
// 날짜 필터
if (params.startDate) {
filteredSites = filteredSites.filter(
(site) => new Date(site.createdAt) >= new Date(params.startDate!)
);
}
if (params.endDate) {
filteredSites = filteredSites.filter(
(site) => new Date(site.createdAt) <= new Date(params.endDate!)
);
}
return {
success: true,
data: {
items: filteredSites,
totalCount: filteredSites.length,
},
};
} catch (error) {
console.error('getSiteList error:', error);
return {
success: false,
error: '현장 목록을 불러오는데 실패했습니다.',
};
}
}
// 현장 통계 조회
export async function getSiteStats(): Promise<{ success: boolean; data?: SiteStats; error?: string }> {
try {
// TODO: API 연동 시 실제 API 호출로 변경
await new Promise((resolve) => setTimeout(resolve, 300));
const total = MOCK_SITES.length;
const construction = MOCK_SITES.filter((s) => s.status === 'active').length;
const unregistered = MOCK_SITES.filter((s) => s.status === 'unregistered').length;
return {
success: true,
data: {
total,
construction,
unregistered,
},
};
} catch (error) {
console.error('getSiteStats error:', error);
return {
success: false,
error: '현장 통계를 불러오는데 실패했습니다.',
};
}
}
// 현장 삭제
export async function deleteSite(id: string): Promise<{ success: boolean; error?: string }> {
try {
// TODO: API 연동 시 실제 API 호출로 변경
await new Promise((resolve) => setTimeout(resolve, 500));
return { success: true };
} catch (error) {
console.error('deleteSite error:', error);
return {
success: false,
error: '현장 삭제에 실패했습니다.',
};
}
}
// 현장 일괄 삭제
export async function deleteSites(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
try {
// TODO: API 연동 시 실제 API 호출로 변경
await new Promise((resolve) => setTimeout(resolve, 500));
return {
success: true,
deletedCount: ids.length,
};
} catch (error) {
console.error('deleteSites error:', error);
return {
success: false,
error: '현장 일괄 삭제에 실패했습니다.',
};
}
}

View File

@@ -0,0 +1,4 @@
export { default as SiteManagementListClient } from './SiteManagementListClient';
export { default as SiteDetailForm } from './SiteDetailForm';
export * from './types';
export * from './actions';

View File

@@ -0,0 +1,57 @@
// 현장 타입
export interface Site {
id: string;
siteCode: string; // 현장번호
partnerId: string;
partnerName: string; // 거래처
siteName: string; // 현장명
address: string; // 위치
status: SiteStatus;
createdAt: string;
updatedAt: string;
}
// 현장 상태
export type SiteStatus = 'unregistered' | 'suspended' | 'active' | 'pending';
// 현장 통계
export interface SiteStats {
total: number; // 전체 현장
construction: number; // 시공 현장
unregistered: number; // 미등록 현장
}
// 상태 옵션
export const SITE_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'unregistered', label: '미등록' },
{ value: 'suspended', label: '중지' },
{ value: 'active', label: '사용' },
{ value: 'pending', label: '보류' },
] as const;
// 정렬 옵션
export const SITE_SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'siteNameAsc', label: '현장명 오름차순' },
{ value: 'siteNameDesc', label: '현장명 내림차순' },
] as const;
// 상태 라벨
export const SITE_STATUS_LABELS: Record<SiteStatus, string> = {
unregistered: '미등록',
suspended: '중지',
active: '사용',
pending: '보류',
};
// 상태 스타일
export const SITE_STATUS_STYLES: Record<SiteStatus, string> = {
unregistered: 'px-2 py-1 rounded-full text-xs bg-red-100 text-red-700',
suspended: 'px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-700',
active: 'px-2 py-1 rounded-full text-xs bg-green-100 text-green-700',
pending: 'px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-700',
};

View File

@@ -0,0 +1,544 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { ClipboardCheck, Upload, X, FileText, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import type { StructureReview, StructureReviewStatus } from './types';
import { STRUCTURE_REVIEW_STATUS_OPTIONS } from './types';
import { deleteStructureReview } from './actions';
// 목업 거래처 목록
const MOCK_PARTNERS = [
{ value: '1', label: '거래처명A' },
{ value: '2', label: '거래처명B' },
{ value: '3', label: '거래처명C' },
];
// 목업 현장 목록
const MOCK_SITES = [
{ value: '1', label: '현장A' },
{ value: '2', label: '현장B' },
{ value: '3', label: '현장C' },
];
// 파일 타입 정의
interface ReviewFile {
id: string;
fileName: string;
fileUrl: string;
fileSize: number;
uploadedAt: string;
}
interface StructureReviewDetailFormProps {
review?: StructureReview;
mode?: 'view' | 'edit' | 'new';
}
export default function StructureReviewDetailForm({
review,
mode = 'view',
}: StructureReviewDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
const isNewMode = mode === 'new';
// 파일 업로드 ref
const fileInputRef = useRef<HTMLInputElement>(null);
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
// 목데이터: 기존 구조검토 파일
const MOCK_REVIEW_FILES: ReviewFile[] = review ? [
{
id: '1',
fileName: '구조검토_보고서_최종.pdf',
fileUrl: '#',
fileSize: 3145728, // 3MB
uploadedAt: '2024-12-10T11:00:00',
},
{
id: '2',
fileName: '구조계산서_v3.xlsx',
fileUrl: '#',
fileSize: 1572864, // 1.5MB
uploadedAt: '2024-12-12T16:45:00',
},
] : [];
// 파일 목록
const [reviewFiles, setReviewFiles] = useState<ReviewFile[]>(MOCK_REVIEW_FILES);
// 폼 상태
const [formData, setFormData] = useState({
reviewNumber: review?.reviewNumber || '',
partnerId: review?.partnerId || '',
siteId: review?.siteId || '',
status: review?.status || 'pending',
reviewCompany: review?.reviewCompany || '',
reviewerName: review?.reviewerName || '',
requestDate: review?.requestDate || '',
completionDate: review?.completionDate || '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// 입력 핸들러
const handleInputChange = useCallback(
(field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
},
[]
);
const handleSelectChange = useCallback((field: string) => (value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
// 수정 버튼 클릭
const handleEditClick = useCallback(() => {
if (review?.id) {
router.push(`/ko/juil/order/structure-review/${review.id}/edit`);
}
}, [router, review?.id]);
// 저장
const handleSubmit = useCallback(async () => {
if (!formData.partnerId) {
toast.error('거래처를 선택해주세요.');
return;
}
if (!formData.siteId) {
toast.error('현장을 선택해주세요.');
return;
}
setIsSubmitting(true);
try {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('저장되었습니다.');
router.push('/ko/juil/order/structure-review');
} catch {
toast.error('저장에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
}, [formData, router]);
// 취소
const handleCancel = useCallback(() => {
router.back();
}, [router]);
// 삭제
const handleDeleteClick = useCallback(() => {
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!review?.id) return;
setIsSubmitting(true);
try {
const result = await deleteStructureReview(review.id);
if (result.success) {
toast.success('삭제되었습니다.');
router.push('/ko/juil/order/structure-review');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsSubmitting(false);
setDeleteDialogOpen(false);
}
}, [review?.id, router]);
// 목록으로 이동
const handleGoToList = useCallback(() => {
router.push('/ko/juil/order/structure-review');
}, [router]);
// 파일 업로드 핸들러
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 파일 크기 검증 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error('파일 크기는 10MB 이하여야 합니다.');
return;
}
const doc: ReviewFile = {
id: String(Date.now()),
fileName: file.name,
fileUrl: URL.createObjectURL(file),
fileSize: file.size,
uploadedAt: new Date().toISOString(),
};
setReviewFiles((prev) => [...prev, doc]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, []);
// 파일 삭제 핸들러
const handleFileRemove = useCallback((docId: string) => {
setReviewFiles((prev) => prev.filter((d) => d.id !== docId));
}, []);
// 드래그 핸들러
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
if (!isViewMode) {
setIsDragging(true);
}
}, [isViewMode]);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (isViewMode) return;
const files = Array.from(e.dataTransfer.files);
files.forEach((file) => {
// 파일 크기 검증 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
return;
}
const doc: ReviewFile = {
id: String(Date.now() + Math.random()),
fileName: file.name,
fileUrl: URL.createObjectURL(file),
fileSize: file.size,
uploadedAt: new Date().toISOString(),
};
setReviewFiles((prev) => [...prev, doc]);
});
}, [isViewMode]);
// 타이틀 결정
const getTitle = () => {
if (isNewMode) return '구조검토 등록';
if (isEditMode) return '구조검토 수정';
return '구조검토 상세';
};
return (
<>
<PageLayout>
<PageHeader
title={getTitle()}
description="구조검토 의뢰 정보를 등록하고 관리합니다"
icon={ClipboardCheck}
actions={
isViewMode ? (
<>
<Button variant="outline" onClick={handleGoToList}>
</Button>
<Button variant="outline" onClick={handleDeleteClick}>
</Button>
<Button onClick={handleEditClick}></Button>
</>
) : (
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? '저장 중...' : '저장'}
</Button>
</>
)
}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> *</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 검토번호 */}
<div className="space-y-2">
<Label htmlFor="reviewNumber"></Label>
<Input
id="reviewNumber"
value={formData.reviewNumber}
onChange={handleInputChange('reviewNumber')}
placeholder="검토번호"
disabled={isViewMode}
/>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="partnerId"></Label>
<Select
value={formData.partnerId}
onValueChange={handleSelectChange('partnerId')}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_PARTNERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label htmlFor="siteId"></Label>
<Select
value={formData.siteId}
onValueChange={handleSelectChange('siteId')}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="현장 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_SITES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={handleSelectChange('status')}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{STRUCTURE_REVIEW_STATUS_OPTIONS.filter((opt) => opt.value !== 'all').map(
(option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 구조검토 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 구조검토 회사 */}
<div className="space-y-2">
<Label htmlFor="reviewCompany"> </Label>
<Input
id="reviewCompany"
value={formData.reviewCompany}
onChange={handleInputChange('reviewCompany')}
placeholder="회사명"
disabled={isViewMode}
/>
</div>
{/* 구조검토자 */}
<div className="space-y-2">
<Label htmlFor="reviewerName"></Label>
<Input
id="reviewerName"
value={formData.reviewerName}
onChange={handleInputChange('reviewerName')}
placeholder="담당자명"
disabled={isViewMode}
/>
</div>
{/* 구조검토 의뢰일 */}
<div className="space-y-2">
<Label htmlFor="requestDate"> </Label>
<Input
id="requestDate"
type="date"
value={formData.requestDate}
onChange={handleInputChange('requestDate')}
disabled={isViewMode}
/>
</div>
{/* 구조검토 완료일 */}
<div className="space-y-2">
<Label htmlFor="completionDate"> </Label>
<Input
id="completionDate"
type="date"
value={formData.completionDate || ''}
onChange={handleInputChange('completionDate')}
disabled={isViewMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 구조검토 파일 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<input
ref={fileInputRef}
type="file"
onChange={handleFileUpload}
className="hidden"
/>
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isViewMode
? 'bg-gray-50'
: isDragging
? 'border-primary bg-primary/5'
: 'hover:border-primary/50 cursor-pointer'
}`}
onClick={() => !isViewMode && fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className={`mx-auto h-8 w-8 mb-2 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`} />
<p className="text-sm text-muted-foreground">
{isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'}
</p>
</div>
{/* 업로드된 파일 목록 */}
{reviewFiles.length > 0 && (
<div className="space-y-2">
{reviewFiles.map((doc) => (
<div key={doc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-primary" />
<div>
<p className="text-sm font-medium">{doc.fileName}</p>
<p className="text-xs text-gray-500">
{(doc.fileSize / 1024).toFixed(1)} KB
</p>
</div>
</div>{isViewMode ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
// 실제로는 doc.fileUrl로 다운로드
const link = document.createElement('a');
link.href = doc.fileUrl;
link.download = doc.fileName;
link.click();
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
}}
>
<Download className="h-4 w-4 mr-1" />
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleFileRemove(doc.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</PageLayout>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,536 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ClipboardCheck, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { StructureReview, StructureReviewStats } from './types';
import {
STRUCTURE_REVIEW_STATUS_OPTIONS,
STRUCTURE_REVIEW_SORT_OPTIONS,
STRUCTURE_REVIEW_STATUS_STYLES,
STRUCTURE_REVIEW_STATUS_LABELS,
} from './types';
import {
getStructureReviewList,
getStructureReviewStats,
deleteStructureReview,
deleteStructureReviews,
} from './actions';
// 테이블 컬럼 정의
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'reviewNumber', label: '검토번호', className: 'w-[100px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'requestDate', label: '구조검토 의뢰일', className: 'w-[120px]' },
{ key: 'reviewCompany', label: '검토회사', className: 'w-[100px]' },
{ key: 'reviewerName', label: '검토자', className: 'w-[80px]' },
{ key: 'completionDate', label: '구조검토완료일', className: 'w-[120px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
];
// 목업 거래처 목록
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: '1', label: '회사명A' },
{ value: '2', label: '회사명B' },
{ value: '3', label: '회사명C' },
];
interface StructureReviewListClientProps {
initialData?: StructureReview[];
initialStats?: StructureReviewStats;
}
export default function StructureReviewListClient({
initialData = [],
initialStats,
}: StructureReviewListClientProps) {
const router = useRouter();
// 상태
const [reviews, setReviews] = useState<StructureReview[]>(initialData);
const [stats, setStats] = useState<StructureReviewStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getStructureReviewList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getStructureReviewStats(),
]);
if (listResult.success && listResult.data) {
setReviews(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredReviews = useMemo(() => {
return reviews.filter((review) => {
// 상태 탭 필터
if (activeStatTab === 'pending' && review.status !== 'pending') return false;
if (activeStatTab === 'completed' && review.status !== 'completed') return false;
// 거래처 필터
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(review.partnerId)) return false;
}
// 상태 필터
if (statusFilter !== 'all' && review.status !== statusFilter) return false;
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
review.reviewNumber.toLowerCase().includes(search) ||
review.partnerName.toLowerCase().includes(search) ||
review.siteName.toLowerCase().includes(search) ||
review.reviewCompany.toLowerCase().includes(search)
);
}
return true;
});
}, [reviews, activeStatTab, partnerFilters, statusFilter, searchValue]);
// 정렬
const sortedReviews = useMemo(() => {
const sorted = [...filteredReviews];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'siteNameAsc':
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
break;
case 'siteNameDesc':
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
break;
}
return sorted;
}, [filteredReviews, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedReviews.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedReviews.slice(start, start + itemsPerPage);
}, [sortedReviews, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((r) => r.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(review: StructureReview) => {
router.push(`/ko/juil/order/structure-review/${review.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, reviewId: string) => {
e.stopPropagation();
router.push(`/ko/juil/order/structure-review/${reviewId}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, reviewId: string) => {
e.stopPropagation();
setDeleteTargetId(reviewId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteStructureReview(deleteTargetId);
if (result.success) {
toast.success('구조검토가 삭제되었습니다.');
setReviews((prev) => prev.filter((r) => r.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteStructureReviews(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
const handleRegister = useCallback(() => {
router.push('/ko/juil/order/structure-review/new');
}, [router]);
// 날짜 포맷
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
};
// 테이블 행 렌더링
const renderTableRow = useCallback(
(review: StructureReview, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(review.id);
return (
<TableRow
key={review.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(review)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(review.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{review.reviewNumber}</TableCell>
<TableCell>{review.partnerName}</TableCell>
<TableCell>{review.siteName}</TableCell>
<TableCell>{formatDate(review.requestDate)}</TableCell>
<TableCell>{review.reviewCompany}</TableCell>
<TableCell>{review.reviewerName}</TableCell>
<TableCell>{formatDate(review.completionDate)}</TableCell>
<TableCell className="text-center">
<span className={STRUCTURE_REVIEW_STATUS_STYLES[review.status]}>
{STRUCTURE_REVIEW_STATUS_LABELS[review.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, review.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, review.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(review: StructureReview, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={review.siteName}
subtitle={review.reviewNumber}
badge={STRUCTURE_REVIEW_STATUS_LABELS[review.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(review)}
details={[
{ label: '거래처', value: review.partnerName },
{ label: '의뢰일', value: formatDate(review.requestDate) },
{ label: '검토회사', value: review.reviewCompany },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션
const headerActions = (
<div className="flex items-center gap-2">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
<Button onClick={handleRegister}> </Button>
</div>
);
// Stats 카드 데이터
const statsCardsData: StatCard[] = [
{
label: '전체 구조검토',
value: stats?.total ?? 0,
icon: ClipboardCheck,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '검토대기',
value: stats?.pending ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => setActiveStatTab('pending'),
isActive: activeStatTab === 'pending',
},
{
label: '검토완료',
value: stats?.completed ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('completed'),
isActive: activeStatTab === 'completed',
},
];
// 테이블 헤더 액션
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedReviews.length}
</span>
{/* 거래처 필터 */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{STRUCTURE_REVIEW_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{STRUCTURE_REVIEW_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="구조검토관리"
description="구조검토 의뢰를 관리합니다"
icon={ClipboardCheck}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="검토번호, 거래처, 현장명, 검토회사 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedReviews}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedReviews.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,229 @@
'use server';
import type { StructureReview, StructureReviewStats } from './types';
// 목업 데이터
const MOCK_STRUCTURE_REVIEWS: StructureReview[] = [
{
id: '1',
reviewNumber: '123123',
partnerId: '1',
partnerName: '회사명',
siteId: '1',
siteName: '현장명',
requestDate: '2025-12-12',
reviewCompany: '회사명',
reviewerName: '홍길동',
reviewDate: '2025-12-15',
completionDate: '2025-12-15',
status: 'pending',
createdAt: '2025-12-01T00:00:00Z',
updatedAt: '2025-12-01T00:00:00Z',
},
{
id: '2',
reviewNumber: '123123',
partnerId: '1',
partnerName: '회사명',
siteId: '2',
siteName: '현장명',
requestDate: '2025-12-12',
reviewCompany: '회사명',
reviewerName: '홍길동',
reviewDate: '2025-12-15',
completionDate: null,
status: 'pending',
createdAt: '2025-12-02T00:00:00Z',
updatedAt: '2025-12-02T00:00:00Z',
},
{
id: '3',
reviewNumber: '123123',
partnerId: '2',
partnerName: '회사명',
siteId: '3',
siteName: '현장명',
requestDate: '2025-12-12',
reviewCompany: '회사명',
reviewerName: '홍길동',
reviewDate: null,
completionDate: null,
status: 'pending',
createdAt: '2025-12-03T00:00:00Z',
updatedAt: '2025-12-03T00:00:00Z',
},
{
id: '4',
reviewNumber: '123123',
partnerId: '2',
partnerName: '회사명',
siteId: '4',
siteName: '현장명',
requestDate: '2025-12-12',
reviewCompany: '회사명',
reviewerName: '홍길동',
reviewDate: '2025-12-15',
completionDate: '2025-12-15',
status: 'completed',
createdAt: '2025-12-04T00:00:00Z',
updatedAt: '2025-12-04T00:00:00Z',
},
{
id: '5',
reviewNumber: '123123',
partnerId: '3',
partnerName: '회사명',
siteId: '5',
siteName: '현장명',
requestDate: '2025-12-12',
reviewCompany: '회사명',
reviewerName: '홍길동',
reviewDate: '2025-12-15',
completionDate: '2025-12-15',
status: 'completed',
createdAt: '2025-12-05T00:00:00Z',
updatedAt: '2025-12-05T00:00:00Z',
},
];
// 구조검토 목록 조회
export async function getStructureReviewList(params?: {
size?: number;
startDate?: string;
endDate?: string;
}): Promise<{
success: boolean;
data?: { items: StructureReview[]; total: number };
error?: string;
}> {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 500));
return {
success: true,
data: {
items: MOCK_STRUCTURE_REVIEWS,
total: MOCK_STRUCTURE_REVIEWS.length,
},
};
}
// 구조검토 통계 조회
export async function getStructureReviewStats(): Promise<{
success: boolean;
data?: StructureReviewStats;
error?: string;
}> {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 300));
const pending = MOCK_STRUCTURE_REVIEWS.filter((r) => r.status === 'pending').length;
const completed = MOCK_STRUCTURE_REVIEWS.filter((r) => r.status === 'completed').length;
return {
success: true,
data: {
total: MOCK_STRUCTURE_REVIEWS.length,
pending,
completed,
},
};
}
// 구조검토 상세 조회
export async function getStructureReview(id: string): Promise<{
success: boolean;
data?: StructureReview;
error?: string;
}> {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 300));
const review = MOCK_STRUCTURE_REVIEWS.find((r) => r.id === id);
if (!review) {
return { success: false, error: '구조검토 정보를 찾을 수 없습니다.' };
}
return { success: true, data: review };
}
// 구조검토 생성
export async function createStructureReview(data: Partial<StructureReview>): Promise<{
success: boolean;
data?: StructureReview;
error?: string;
}> {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 500));
return {
success: true,
data: {
id: String(Date.now()),
reviewNumber: data.reviewNumber || '',
partnerId: data.partnerId || '',
partnerName: data.partnerName || '',
siteId: data.siteId || '',
siteName: data.siteName || '',
requestDate: data.requestDate || '',
reviewCompany: data.reviewCompany || '',
reviewerName: data.reviewerName || '',
reviewDate: data.reviewDate || null,
completionDate: data.completionDate || null,
status: data.status || 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
}
// 구조검토 수정
export async function updateStructureReview(
id: string,
data: Partial<StructureReview>
): Promise<{
success: boolean;
data?: StructureReview;
error?: string;
}> {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 500));
const existing = MOCK_STRUCTURE_REVIEWS.find((r) => r.id === id);
if (!existing) {
return { success: false, error: '구조검토 정보를 찾을 수 없습니다.' };
}
return {
success: true,
data: {
...existing,
...data,
updatedAt: new Date().toISOString(),
},
};
}
// 구조검토 삭제
export async function deleteStructureReview(id: string): Promise<{
success: boolean;
error?: string;
}> {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 500));
return { success: true };
}
// 구조검토 일괄 삭제
export async function deleteStructureReviews(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
// TODO: API 연동
await new Promise((resolve) => setTimeout(resolve, 500));
return { success: true, deletedCount: ids.length };
}

View File

@@ -0,0 +1,4 @@
export { default as StructureReviewListClient } from './StructureReviewListClient';
export { default as StructureReviewDetailForm } from './StructureReviewDetailForm';
export * from './types';
export * from './actions';

View File

@@ -0,0 +1,57 @@
// 구조검토 타입
export interface StructureReview {
id: string;
reviewNumber: string; // 검토번호
partnerId: string;
partnerName: string; // 거래처
siteId: string;
siteName: string; // 현장명
requestDate: string; // 구조검토 의뢰일
reviewCompany: string; // 구조검토 회사
reviewerName: string; // 구조검토자
reviewDate: string | null; // 구조검토일
completionDate: string | null; // 구조검토 완료일
status: StructureReviewStatus;
fileUrl?: string; // 구조검토 파일
createdAt: string;
updatedAt: string;
}
// 구조검토 상태
export type StructureReviewStatus = 'pending' | 'completed';
// 구조검토 통계
export interface StructureReviewStats {
total: number; // 전체 구조검토
pending: number; // 검토대기
completed: number; // 검토완료
}
// 상태 옵션
export const STRUCTURE_REVIEW_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'pending', label: '검토대기' },
{ value: 'completed', label: '검토완료' },
] as const;
// 정렬 옵션
export const STRUCTURE_REVIEW_SORT_OPTIONS = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'siteNameAsc', label: '현장명 오름차순' },
{ value: 'siteNameDesc', label: '현장명 내림차순' },
] as const;
// 상태 라벨
export const STRUCTURE_REVIEW_STATUS_LABELS: Record<StructureReviewStatus, string> = {
pending: '검토대기',
completed: '검토완료',
};
// 상태 스타일
export const STRUCTURE_REVIEW_STATUS_STYLES: Record<StructureReviewStatus, string> = {
pending: 'px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-700',
completed: 'px-2 py-1 rounded-full text-xs bg-green-100 text-green-700',
};

View File

@@ -0,0 +1,81 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/components/ui/utils';
import type { CalendarHeaderProps, CalendarView } from './types';
import { formatYearMonth } from './utils';
/**
* 달력 헤더 컴포넌트
* - 년월 표시 및 네비게이션 (◀ ▶)
* - 주/월 뷰 전환 탭
* - 필터 slot (children으로 외부 주입)
*/
export function CalendarHeader({
currentDate,
view,
onPrevMonth,
onNextMonth,
onViewChange,
filterSlot,
}: CalendarHeaderProps) {
const views: { value: CalendarView; label: string }[] = [
{ value: 'week', label: '주' },
{ value: 'month', label: '월' },
];
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pb-3 border-b">
{/* 좌측: 년월 네비게이션 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onPrevMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-lg font-bold min-w-[120px] text-center">
{formatYearMonth(currentDate)}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 우측: 뷰 전환 + 필터 */}
<div className="flex items-center gap-3">
{/* 뷰 전환 탭 */}
<div className="flex rounded-md border">
{views.map((v) => (
<button
key={v.value}
onClick={() => onViewChange(v.value)}
className={cn(
'px-4 py-1.5 text-sm font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
view === v.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-primary/10 text-foreground'
)}
>
{v.label}
</button>
))}
</div>
{/* 필터 슬롯 */}
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { cn } from '@/components/ui/utils';
import type { DayBadge } from './types';
import { BADGE_COLORS } from './types';
import { format } from 'date-fns';
interface DayCellProps {
date: Date;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isWeekend: boolean;
badge?: DayBadge;
onClick: (date: Date) => void;
}
/**
* 일자 셀 컴포넌트
* - 날짜 숫자 표시
* - 뱃지 숫자 표시 (빨간 원)
* - 클릭 이벤트 처리
* - 선택/오늘 상태 스타일
*/
export function DayCell({
date,
isCurrentMonth,
isToday,
isSelected,
isWeekend,
badge,
onClick,
}: DayCellProps) {
const dayNumber = format(date, 'd');
const badgeColor = badge?.color || 'red';
return (
<button
type="button"
onClick={() => onClick(date)}
className={cn(
'relative w-full h-8 flex items-center justify-center',
'text-sm font-medium transition-colors rounded-md',
'hover:bg-primary/10',
// 현재 월 여부
isCurrentMonth ? 'text-foreground' : 'text-muted-foreground/40',
// 주말 색상
isWeekend && isCurrentMonth && 'text-red-500',
// 오늘
isToday && 'bg-accent text-accent-foreground font-bold',
// 선택됨
isSelected && 'bg-primary text-primary-foreground hover:bg-primary'
)}
>
{/* 날짜 숫자 */}
<span>{dayNumber}</span>
{/* 뱃지 */}
{badge && badge.count > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1',
'flex items-center justify-center',
'text-[10px] font-bold rounded-full',
BADGE_COLORS[badgeColor] || BADGE_COLORS.red
)}
>
{badge.count > 99 ? '99+' : badge.count}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useMemo } from 'react';
import { cn } from '@/components/ui/utils';
import { DayCell } from './DayCell';
import { ScheduleBar } from './ScheduleBar';
import { MorePopover } from './MorePopover';
import type { MonthViewProps } from './types';
import {
getMonthDays,
getWeekdayHeaders,
isCurrentMonth,
checkIsToday,
isSameDate,
splitIntoWeeks,
getEventSegmentsForWeek,
assignEventRows,
getEventsForDate,
getBadgeForDate,
} from './utils';
import { getDay } from 'date-fns';
/**
* 월간 뷰 컴포넌트
* - 월간 그리드 레이아웃 (7xN)
* - 요일 헤더 (일~토)
* - 날짜 셀 렌더링
* - 일정 바 렌더링
* - +N 더보기 표시
*/
export function MonthView({
currentDate,
events,
badges,
selectedDate,
maxEventsPerDay,
weekStartsOn,
onDateClick,
onEventClick,
}: MonthViewProps) {
// 요일 헤더
const weekdayHeaders = useMemo(
() => getWeekdayHeaders(weekStartsOn),
[weekStartsOn]
);
// 월간 날짜 배열
const monthDays = useMemo(
() => getMonthDays(currentDate, weekStartsOn),
[currentDate, weekStartsOn]
);
// 주 단위로 분할
const weeks = useMemo(() => splitIntoWeeks(monthDays), [monthDays]);
return (
<div className="flex flex-col">
{/* 요일 헤더 */}
<div className="grid grid-cols-7 mb-1">
{weekdayHeaders.map((day, index) => {
const isWeekend =
(weekStartsOn === 0 && (index === 0 || index === 6)) ||
(weekStartsOn === 1 && (index === 5 || index === 6));
return (
<div
key={day}
className={cn(
'text-center text-xs font-semibold py-2',
'text-muted-foreground',
isWeekend && 'text-red-400'
)}
>
{day}
</div>
);
})}
</div>
{/* 주 단위 렌더링 */}
<div className="flex flex-col border rounded-md overflow-hidden">
{weeks.map((weekDays, weekIndex) => (
<WeekRow
key={weekIndex}
weekDays={weekDays}
events={events}
badges={badges}
currentDate={currentDate}
selectedDate={selectedDate}
maxEventsPerDay={maxEventsPerDay}
weekStartsOn={weekStartsOn}
onDateClick={onDateClick}
onEventClick={onEventClick}
/>
))}
</div>
</div>
);
}
interface WeekRowProps {
weekDays: Date[];
events: import('./types').ScheduleEvent[];
badges: import('./types').DayBadge[];
currentDate: Date;
selectedDate: Date | null;
maxEventsPerDay: number;
weekStartsOn: 0 | 1;
onDateClick: (date: Date) => void;
onEventClick: (event: import('./types').ScheduleEvent) => void;
}
function WeekRow({
weekDays,
events,
badges,
currentDate,
selectedDate,
maxEventsPerDay,
weekStartsOn,
onDateClick,
onEventClick,
}: WeekRowProps) {
// 이 주에 해당하는 이벤트 세그먼트 계산
const eventSegments = useMemo(
() => getEventSegmentsForWeek(events, weekDays, weekStartsOn),
[events, weekDays, weekStartsOn]
);
// 이벤트 행 배치
const rowAssignments = useMemo(
() => assignEventRows(eventSegments),
[eventSegments]
);
// 표시할 이벤트 수 계산 (maxEventsPerDay 초과 시 +N 표시)
const visibleRows = maxEventsPerDay;
// 각 날짜별 숨겨진 이벤트 수 계산
const hiddenEventCounts = useMemo(() => {
const counts: Map<number, number> = new Map();
weekDays.forEach((date, colIndex) => {
const dayEvents = getEventsForDate(events, date);
const hidden = Math.max(0, dayEvents.length - visibleRows);
if (hidden > 0) {
counts.set(colIndex, hidden);
}
});
return counts;
}, [weekDays, events, visibleRows]);
// 셀 최소 높이 계산 (이벤트 행 수에 따라) - 더 넉넉하게 확보
const maxRowIndex = Math.max(0, ...Array.from(rowAssignments.values()));
const rowHeight = Math.max(120, 40 + Math.min(maxRowIndex + 1, visibleRows) * 28 + 24);
return (
<div
className="relative grid grid-cols-7 border-b last:border-b-0"
style={{ minHeight: `${rowHeight}px` }}
>
{/* 날짜 셀들 */}
{weekDays.map((date, colIndex) => {
const dayOfWeek = getDay(date);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const badge = getBadgeForDate(badges, date);
const hiddenCount = hiddenEventCounts.get(colIndex) || 0;
const dayEvents = getEventsForDate(events, date);
const isSelected = isSameDate(selectedDate, date);
return (
<div
key={date.toISOString()}
className={cn(
'relative p-1 border-r last:border-r-0',
'flex flex-col cursor-pointer transition-colors',
// 기본 배경
!isCurrentMonth(date, currentDate) && 'bg-muted/30',
// 선택된 날짜 - 셀 전체 배경색 변경 (테두리 없이)
isSelected && 'bg-primary/15'
)}
onClick={() => onDateClick(date)}
>
{/* 날짜 셀 */}
<DayCell
date={date}
isCurrentMonth={isCurrentMonth(date, currentDate)}
isToday={checkIsToday(date)}
isSelected={isSelected}
isWeekend={isWeekend}
badge={badge}
onClick={onDateClick}
/>
{/* 더보기 버튼 (하단에 배치) */}
{hiddenCount > 0 && (
<div className="absolute bottom-1 left-1">
<MorePopover
date={date}
events={dayEvents}
hiddenCount={hiddenCount}
onEventClick={onEventClick}
onDateClick={onDateClick}
/>
</div>
)}
</div>
);
})}
{/* 이벤트 바들 (절대 위치) */}
{eventSegments
.filter((segment) => {
const rowIndex = rowAssignments.get(segment.event.id) || 0;
return rowIndex < visibleRows;
})
.map((segment) => {
const rowIndex = rowAssignments.get(segment.event.id) || 0;
return (
<ScheduleBar
key={`${segment.event.id}-${weekDays[0].toISOString()}`}
event={segment.event}
isStart={segment.isStart}
isEnd={segment.isEnd}
colSpan={segment.colSpan}
startCol={segment.startCol}
rowIndex={rowIndex}
onClick={onEventClick}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { cn } from '@/components/ui/utils';
import type { ScheduleEvent } from './types';
interface MorePopoverProps {
date: Date;
events: ScheduleEvent[];
hiddenCount: number;
onEventClick: (event: ScheduleEvent) => void;
onDateClick?: (date: Date) => void;
}
/**
* 더보기 버튼 컴포넌트
* - +N 버튼 렌더링
* - 클릭 시 해당 날짜 선택 (테이블 필터링)
*/
export function MorePopover({
date,
hiddenCount,
onDateClick,
}: MorePopoverProps) {
if (hiddenCount <= 0) return null;
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
// 날짜 선택 → 테이블 필터링
onDateClick?.(date);
};
return (
<button
type="button"
className={cn(
'text-xs font-medium text-muted-foreground',
'hover:text-primary hover:underline',
'transition-colors cursor-pointer'
)}
onClick={handleClick}
>
+{hiddenCount}
</button>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { cn } from '@/components/ui/utils';
import type { ScheduleEvent } from './types';
import { EVENT_COLORS } from './types';
interface ScheduleBarProps {
event: ScheduleEvent;
/** 현재 주에서 시작하는지 */
isStart: boolean;
/** 현재 주에서 끝나는지 */
isEnd: boolean;
/** 차지하는 컬럼 수 (1-7) */
colSpan: number;
/** 시작 위치 (0-6) */
startCol: number;
/** 행 인덱스 (겹치는 이벤트 처리) */
rowIndex: number;
onClick: (event: ScheduleEvent) => void;
}
/**
* 일정 바 컴포넌트
* - 일정 바 렌더링 (시작~종료 날짜)
* - 여러 날에 걸치는 바 표시
* - 색상 구분 (상태별)
* - 호버/클릭 이벤트
*/
export function ScheduleBar({
event,
isStart,
isEnd,
colSpan,
startCol,
rowIndex,
onClick,
}: ScheduleBarProps) {
const color = event.color || 'blue';
const colorClass = EVENT_COLORS[color] || EVENT_COLORS.blue;
// 컬럼 너비 계산 (각 셀은 1/7)
const widthPercent = (colSpan / 7) * 100;
const leftPercent = (startCol / 7) * 100;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClick(event);
}}
className={cn(
'absolute h-5 px-2 text-xs font-medium truncate',
'transition-all hover:opacity-80 hover:shadow-sm',
'flex items-center cursor-pointer',
colorClass,
// 라운드 처리
isStart && isEnd && 'rounded-md',
isStart && !isEnd && 'rounded-l-md',
!isStart && isEnd && 'rounded-r-md',
!isStart && !isEnd && 'rounded-none'
)}
style={{
width: `calc(${widthPercent}% - 4px)`,
left: `calc(${leftPercent}% + 2px)`,
top: `${rowIndex * 24 + 32}px`, // 날짜 영역(32px) 아래부터 시작
}}
title={event.title}
>
{isStart && <span className="truncate">{event.title}</span>}
</button>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { cn } from '@/components/ui/utils';
import { CalendarHeader } from './CalendarHeader';
import { MonthView } from './MonthView';
import { WeekView } from './WeekView';
import type { ScheduleCalendarProps, CalendarView } from './types';
import { getNextMonth, getPrevMonth } from './utils';
/**
* 스케줄 달력 공통 컴포넌트
*
* 주/월 뷰 전환, 일정 바 표시, 날짜별 뱃지 등을 지원하는 재사용 가능한 달력
*
* @example
* ```tsx
* <ScheduleCalendar
* events={[
* { id: '1', title: '김담당 - 현장A / ORD-001', startDate: '2025-12-01', endDate: '2025-12-05', color: 'blue' },
* ]}
* badges={[
* { date: '2025-12-01', count: 3, color: 'red' },
* ]}
* onDateClick={(date) => console.log('날짜 클릭:', date)}
* onEventClick={(event) => console.log('이벤트 클릭:', event)}
* filterSlot={<Select>...</Select>}
* />
* ```
*/
export function ScheduleCalendar({
events = [],
badges = [],
currentDate: controlledDate,
view: controlledView,
selectedDate: controlledSelectedDate,
onDateClick,
onEventClick,
onMonthChange,
onViewChange,
filterSlot,
maxEventsPerDay = 3,
weekStartsOn = 0,
isLoading = false,
className,
}: ScheduleCalendarProps) {
// 내부 상태 (controlled/uncontrolled 지원)
const [internalDate, setInternalDate] = useState(() => new Date());
const [internalView, setInternalView] = useState<CalendarView>('month');
const [internalSelectedDate, setInternalSelectedDate] = useState<Date | null>(null);
// 현재 사용할 값 결정
const currentDate = controlledDate ?? internalDate;
const view = controlledView ?? internalView;
const selectedDate = controlledSelectedDate !== undefined ? controlledSelectedDate : internalSelectedDate;
// Hydration 불일치 방지
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// 이전 달
const handlePrevMonth = useCallback(() => {
const newDate = getPrevMonth(currentDate);
if (controlledDate === undefined) {
setInternalDate(newDate);
}
onMonthChange?.(newDate);
}, [currentDate, controlledDate, onMonthChange]);
// 다음 달
const handleNextMonth = useCallback(() => {
const newDate = getNextMonth(currentDate);
if (controlledDate === undefined) {
setInternalDate(newDate);
}
onMonthChange?.(newDate);
}, [currentDate, controlledDate, onMonthChange]);
// 뷰 변경
const handleViewChange = useCallback((newView: CalendarView) => {
if (controlledView === undefined) {
setInternalView(newView);
}
onViewChange?.(newView);
}, [controlledView, onViewChange]);
// 날짜 클릭
const handleDateClick = useCallback((date: Date) => {
if (controlledSelectedDate === undefined) {
setInternalSelectedDate(date);
}
onDateClick?.(date);
}, [controlledSelectedDate, onDateClick]);
// 이벤트 클릭
const handleEventClick = useCallback((event: import('./types').ScheduleEvent) => {
onEventClick?.(event);
}, [onEventClick]);
// SSR에서는 빈 컨테이너 렌더링
if (!mounted) {
return (
<div className={cn('min-h-[400px] rounded-md border p-4', className)} />
);
}
return (
<div className={cn('rounded-md border p-4 bg-background', className)}>
{/* 헤더 */}
<CalendarHeader
currentDate={currentDate}
view={view}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
onViewChange={handleViewChange}
filterSlot={filterSlot}
/>
{/* 본문 */}
<div className="mt-4">
{isLoading ? (
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : view === 'month' ? (
<MonthView
currentDate={currentDate}
events={events}
badges={badges}
selectedDate={selectedDate}
maxEventsPerDay={maxEventsPerDay}
weekStartsOn={weekStartsOn}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
) : (
<WeekView
currentDate={currentDate}
events={events}
badges={badges}
selectedDate={selectedDate}
maxEventsPerDay={maxEventsPerDay}
weekStartsOn={weekStartsOn}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import { useMemo } from 'react';
import { cn } from '@/components/ui/utils';
import { DayCell } from './DayCell';
import { ScheduleBar } from './ScheduleBar';
import { MorePopover } from './MorePopover';
import type { WeekViewProps } from './types';
import {
getWeekDays,
checkIsToday,
isSameDate,
getEventSegmentsForWeek,
assignEventRows,
getEventsForDate,
getBadgeForDate,
} from './utils';
import { format, getDay } from 'date-fns';
import { ko } from 'date-fns/locale';
/**
* 주간 뷰 컴포넌트
* - 주간 그리드 레이아웃 (7 컬럼)
* - 요일 헤더 (날짜 + 요일)
* - 날짜 셀 렌더링
* - 일정 바 렌더링
*/
export function WeekView({
currentDate,
events,
badges,
selectedDate,
maxEventsPerDay,
weekStartsOn,
onDateClick,
onEventClick,
}: WeekViewProps) {
// 주간 날짜 배열
const weekDays = useMemo(
() => getWeekDays(currentDate, weekStartsOn),
[currentDate, weekStartsOn]
);
// 이 주에 해당하는 이벤트 세그먼트 계산
const eventSegments = useMemo(
() => getEventSegmentsForWeek(events, weekDays, weekStartsOn),
[events, weekDays, weekStartsOn]
);
// 이벤트 행 배치
const rowAssignments = useMemo(
() => assignEventRows(eventSegments),
[eventSegments]
);
// 표시할 이벤트 수 계산
const visibleRows = maxEventsPerDay;
// 각 날짜별 숨겨진 이벤트 수 계산
const hiddenEventCounts = useMemo(() => {
const counts: Map<number, number> = new Map();
weekDays.forEach((date, colIndex) => {
const dayEvents = getEventsForDate(events, date);
const hidden = Math.max(0, dayEvents.length - visibleRows);
if (hidden > 0) {
counts.set(colIndex, hidden);
}
});
return counts;
}, [weekDays, events, visibleRows]);
// 셀 최소 높이 계산
const maxRowIndex = Math.max(0, ...Array.from(rowAssignments.values()));
const rowHeight = Math.max(120, 48 + Math.min(maxRowIndex + 1, visibleRows) * 24 + 24);
return (
<div className="flex flex-col">
{/* 요일 헤더 (날짜 + 요일) */}
<div className="grid grid-cols-7 mb-1">
{weekDays.map((date) => {
const dayOfWeek = getDay(date);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isToday = checkIsToday(date);
return (
<div
key={date.toISOString()}
className={cn(
'text-center py-2',
'flex flex-col items-center'
)}
>
<span
className={cn(
'text-xs font-semibold text-muted-foreground',
isWeekend && 'text-red-400'
)}
>
{format(date, 'E', { locale: ko })}
</span>
<span
className={cn(
'text-lg font-bold mt-0.5',
isWeekend && 'text-red-500',
isToday && 'bg-primary text-primary-foreground rounded-full w-8 h-8 flex items-center justify-center'
)}
>
{format(date, 'd')}
</span>
</div>
);
})}
</div>
{/* 주간 본문 */}
<div
className="relative grid grid-cols-7 border rounded-md overflow-hidden"
style={{ minHeight: `${rowHeight}px` }}
>
{/* 날짜 셀들 */}
{weekDays.map((date, colIndex) => {
const dayOfWeek = getDay(date);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const badge = getBadgeForDate(badges, date);
const hiddenCount = hiddenEventCounts.get(colIndex) || 0;
const dayEvents = getEventsForDate(events, date);
return (
<div
key={date.toISOString()}
className={cn(
'relative p-2 border-r last:border-r-0',
'flex flex-col cursor-pointer transition-colors',
// 선택된 날짜 - 배경색 + 호버 시 더 진하게
isSameDate(selectedDate, date)
? 'bg-primary/15 hover:bg-primary/25'
: 'hover:bg-muted/30'
)}
onClick={() => onDateClick(date)}
>
{/* 뱃지 */}
{badge && badge.count > 0 && (
<div className="absolute top-1 right-1">
<span
className={cn(
'min-w-[18px] h-[18px] px-1',
'flex items-center justify-center',
'text-[10px] font-bold rounded-full',
'bg-red-500 text-white'
)}
>
{badge.count > 99 ? '99+' : badge.count}
</span>
</div>
)}
{/* 더보기 버튼 (하단에 배치) */}
{hiddenCount > 0 && (
<div className="absolute bottom-1 left-2">
<MorePopover
date={date}
events={dayEvents}
hiddenCount={hiddenCount}
onEventClick={onEventClick}
onDateClick={onDateClick}
/>
</div>
)}
</div>
);
})}
{/* 이벤트 바들 (절대 위치) */}
{eventSegments
.filter((segment) => {
const rowIndex = rowAssignments.get(segment.event.id) || 0;
return rowIndex < visibleRows;
})
.map((segment) => {
const rowIndex = rowAssignments.get(segment.event.id) || 0;
return (
<ScheduleBar
key={`${segment.event.id}-week`}
event={segment.event}
isStart={segment.isStart}
isEnd={segment.isEnd}
colSpan={segment.colSpan}
startCol={segment.startCol}
rowIndex={rowIndex}
onClick={onEventClick}
/>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
/**
* ScheduleCalendar 공통 컴포넌트
*
* 주/월 뷰 전환, 일정 바 표시, 날짜별 뱃지 등을 지원하는 재사용 가능한 스케줄 달력
*/
export { ScheduleCalendar } from './ScheduleCalendar';
export { CalendarHeader } from './CalendarHeader';
export { MonthView } from './MonthView';
export { WeekView } from './WeekView';
export { DayCell } from './DayCell';
export { ScheduleBar } from './ScheduleBar';
export { MorePopover } from './MorePopover';
export type {
ScheduleCalendarProps,
ScheduleEvent,
DayBadge,
CalendarView,
CalendarHeaderProps,
DayCellProps,
ScheduleBarProps,
MorePopoverProps,
MonthViewProps,
WeekViewProps,
} from './types';
export { EVENT_COLORS, BADGE_COLORS } from './types';
export * from './utils';

View File

@@ -0,0 +1,179 @@
/**
* ScheduleCalendar 공통 컴포넌트 타입 정의
*/
/**
* 달력 뷰 모드
*/
export type CalendarView = 'week' | 'month';
/**
* 일정 이벤트
*/
export interface ScheduleEvent {
/** 이벤트 고유 ID */
id: string;
/** 이벤트 제목 (예: "담당자 - 현장명 / 발주번호") */
title: string;
/** 시작 날짜 (yyyy-MM-dd 형식) */
startDate: string;
/** 종료 날짜 (yyyy-MM-dd 형식) */
endDate: string;
/** 이벤트 색상 (기본 색상명 또는 커스텀) */
color?: string;
/** 이벤트 상태 */
status?: string;
/** 추가 데이터 */
data?: unknown;
}
/**
* 일자별 뱃지 정보
*/
export interface DayBadge {
/** 날짜 (yyyy-MM-dd 형식) */
date: string;
/** 뱃지에 표시할 숫자 */
count: number;
/** 뱃지 색상 */
color?: 'red' | 'blue' | 'yellow' | 'green' | 'gray';
}
/**
* 달력 Props
*/
export interface ScheduleCalendarProps {
/** 일정 이벤트 목록 */
events: ScheduleEvent[];
/** 일자별 뱃지 목록 */
badges?: DayBadge[];
/** 현재 년월 (Date 객체) */
currentDate?: Date;
/** 현재 뷰 모드 */
view?: CalendarView;
/** 선택된 날짜 */
selectedDate?: Date | null;
/** 날짜 클릭 핸들러 */
onDateClick?: (date: Date) => void;
/** 이벤트 클릭 핸들러 */
onEventClick?: (event: ScheduleEvent) => void;
/** 월 변경 핸들러 */
onMonthChange?: (date: Date) => void;
/** 뷰 모드 변경 핸들러 */
onViewChange?: (view: CalendarView) => void;
/** 필터 영역 (slot) */
filterSlot?: React.ReactNode;
/** 최대 표시 이벤트 수 (초과 시 +N 표시) */
maxEventsPerDay?: number;
/** 주 시작 요일 (0: 일요일, 1: 월요일) */
weekStartsOn?: 0 | 1;
/** 로딩 상태 */
isLoading?: boolean;
/** 추가 클래스 */
className?: string;
}
/**
* 캘린더 헤더 Props
*/
export interface CalendarHeaderProps {
currentDate: Date;
view: CalendarView;
onPrevMonth: () => void;
onNextMonth: () => void;
onViewChange: (view: CalendarView) => void;
filterSlot?: React.ReactNode;
}
/**
* 일자 셀 Props
*/
export interface DayCellProps {
date: Date;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
badge?: DayBadge;
events: ScheduleEvent[];
maxEvents: number;
onClick: (date: Date) => void;
onEventClick: (event: ScheduleEvent) => void;
}
/**
* 일정 바 Props
*/
export interface ScheduleBarProps {
event: ScheduleEvent;
/** 현재 주에서 시작하는지 */
isStart: boolean;
/** 현재 주에서 끝나는지 */
isEnd: boolean;
/** 차지하는 컬럼 수 (1-7) */
colSpan: number;
/** 시작 위치 (0-6) */
startCol: number;
/** 행 인덱스 (겹치는 이벤트 처리) */
rowIndex: number;
onClick: (event: ScheduleEvent) => void;
}
/**
* 더보기 팝오버 Props
*/
export interface MorePopoverProps {
date: Date;
events: ScheduleEvent[];
count: number;
onEventClick: (event: ScheduleEvent) => void;
}
/**
* 월간 뷰 Props
*/
export interface MonthViewProps {
currentDate: Date;
events: ScheduleEvent[];
badges: DayBadge[];
selectedDate: Date | null;
maxEventsPerDay: number;
weekStartsOn: 0 | 1;
onDateClick: (date: Date) => void;
onEventClick: (event: ScheduleEvent) => void;
}
/**
* 주간 뷰 Props
*/
export interface WeekViewProps {
currentDate: Date;
events: ScheduleEvent[];
badges: DayBadge[];
selectedDate: Date | null;
maxEventsPerDay: number;
weekStartsOn: 0 | 1;
onDateClick: (date: Date) => void;
onEventClick: (event: ScheduleEvent) => void;
}
/**
* 이벤트 색상 매핑
*/
export const EVENT_COLORS: Record<string, string> = {
gray: 'bg-gray-400 text-white',
blue: 'bg-blue-500 text-white',
yellow: 'bg-yellow-500 text-white',
green: 'bg-green-500 text-white',
red: 'bg-red-500 text-white',
};
/**
* 뱃지 색상 매핑
*/
export const BADGE_COLORS: Record<string, string> = {
red: 'bg-red-500 text-white',
blue: 'bg-blue-500 text-white',
yellow: 'bg-yellow-500 text-white',
green: 'bg-green-500 text-white',
gray: 'bg-gray-500 text-white',
};

View File

@@ -0,0 +1,292 @@
/**
* ScheduleCalendar 유틸리티 함수
*/
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isSameDay,
isToday,
format,
addMonths,
subMonths,
parseISO,
isWithinInterval,
differenceInDays,
addDays,
getDay,
} from 'date-fns';
import { ko } from 'date-fns/locale';
import type { ScheduleEvent, DayBadge } from './types';
/**
* 월간 달력에 표시할 날짜 배열 생성
* (이전 달/다음 달 날짜 포함)
*/
export function getMonthDays(date: Date, weekStartsOn: 0 | 1 = 0): Date[] {
const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date);
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
return eachDayOfInterval({ start: calendarStart, end: calendarEnd });
}
/**
* 주간 달력에 표시할 날짜 배열 생성
*/
export function getWeekDays(date: Date, weekStartsOn: 0 | 1 = 0): Date[] {
const weekStart = startOfWeek(date, { weekStartsOn });
const weekEnd = endOfWeek(date, { weekStartsOn });
return eachDayOfInterval({ start: weekStart, end: weekEnd });
}
/**
* 요일 헤더 배열 생성
*/
export function getWeekdayHeaders(weekStartsOn: 0 | 1 = 0): string[] {
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
if (weekStartsOn === 1) {
return [...weekdays.slice(1), weekdays[0]];
}
return weekdays;
}
/**
* 날짜가 현재 월에 속하는지 확인
*/
export function isCurrentMonth(date: Date, currentDate: Date): boolean {
return isSameMonth(date, currentDate);
}
/**
* 날짜가 오늘인지 확인
*/
export function checkIsToday(date: Date): boolean {
return isToday(date);
}
/**
* 두 날짜가 같은지 확인
*/
export function isSameDate(date1: Date | null, date2: Date): boolean {
if (!date1) return false;
return isSameDay(date1, date2);
}
/**
* 날짜 포맷
*/
export function formatDate(date: Date, formatStr: string = 'yyyy-MM-dd'): string {
return format(date, formatStr, { locale: ko });
}
/**
* 년월 포맷 (예: "2025년 12월")
*/
export function formatYearMonth(date: Date): string {
return format(date, 'yyyy년 M월', { locale: ko });
}
/**
* 다음 달로 이동
*/
export function getNextMonth(date: Date): Date {
return addMonths(date, 1);
}
/**
* 이전 달로 이동
*/
export function getPrevMonth(date: Date): Date {
return subMonths(date, 1);
}
/**
* 특정 날짜에 해당하는 이벤트 필터링
*/
export function getEventsForDate(events: ScheduleEvent[], date: Date): ScheduleEvent[] {
const dateStr = format(date, 'yyyy-MM-dd');
return events.filter((event) => {
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
const targetDate = parseISO(dateStr);
return isWithinInterval(targetDate, { start: startDate, end: endDate });
});
}
/**
* 특정 날짜에 해당하는 뱃지 찾기
*/
export function getBadgeForDate(badges: DayBadge[], date: Date): DayBadge | undefined {
const dateStr = format(date, 'yyyy-MM-dd');
return badges.find((badge) => badge.date === dateStr);
}
/**
* 이벤트가 특정 날짜에 시작하는지 확인
*/
export function isEventStart(event: ScheduleEvent, date: Date): boolean {
return isSameDay(parseISO(event.startDate), date);
}
/**
* 이벤트가 특정 날짜에 끝나는지 확인
*/
export function isEventEnd(event: ScheduleEvent, date: Date): boolean {
return isSameDay(parseISO(event.endDate), date);
}
/**
* 이벤트 기간 (일수)
*/
export function getEventDuration(event: ScheduleEvent): number {
const start = parseISO(event.startDate);
const end = parseISO(event.endDate);
return differenceInDays(end, start) + 1;
}
/**
* 주 단위로 이벤트 분할 (여러 주에 걸치는 이벤트 처리)
*/
export interface WeekEventSegment {
event: ScheduleEvent;
startCol: number; // 0-6 (시작 컬럼)
colSpan: number; // 차지하는 컬럼 수
isStart: boolean; // 이벤트 시작 주인지
isEnd: boolean; // 이벤트 끝 주인지
}
export function getEventSegmentsForWeek(
events: ScheduleEvent[],
weekDays: Date[],
weekStartsOn: 0 | 1 = 0
): WeekEventSegment[] {
const weekStart = weekDays[0];
const weekEnd = weekDays[6];
const segments: WeekEventSegment[] = [];
events.forEach((event) => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
// 이 주와 겹치는지 확인
if (eventEnd < weekStart || eventStart > weekEnd) {
return; // 이 주와 겹치지 않음
}
// 이 주에서의 시작/끝 날짜 계산
const segmentStart = eventStart < weekStart ? weekStart : eventStart;
const segmentEnd = eventEnd > weekEnd ? weekEnd : eventEnd;
// 컬럼 위치 계산
const startCol = getDay(segmentStart);
const adjustedStartCol = weekStartsOn === 1
? (startCol === 0 ? 6 : startCol - 1)
: startCol;
const colSpan = differenceInDays(segmentEnd, segmentStart) + 1;
segments.push({
event,
startCol: adjustedStartCol,
colSpan,
isStart: isSameDay(eventStart, segmentStart),
isEnd: isSameDay(eventEnd, segmentEnd),
});
});
return segments;
}
/**
* 이벤트 행 배치 계산 (다른 색상은 다른 행에 배치)
* - 같은 색상(작업반장)끼리만 같은 행 공유 가능
* - 다른 색상은 무조건 다른 행에 배치
*/
export function assignEventRows(segments: WeekEventSegment[]): Map<string, number> {
const rowMap = new Map<string, number>();
// 색상별로 그룹화
const colorGroups = new Map<string, WeekEventSegment[]>();
segments.forEach((segment) => {
const color = segment.event.color || 'blue';
if (!colorGroups.has(color)) {
colorGroups.set(color, []);
}
colorGroups.get(color)!.push(segment);
});
let currentBaseRow = 0;
// 각 색상 그룹별로 행 배치
colorGroups.forEach((groupSegments) => {
const occupied: boolean[][] = [];
// 시작 컬럼 순으로 정렬
const sortedSegments = [...groupSegments].sort((a, b) => {
if (a.startCol !== b.startCol) return a.startCol - b.startCol;
return b.colSpan - a.colSpan; // 긴 이벤트 먼저
});
let maxRowInGroup = 0;
sortedSegments.forEach((segment) => {
const { event, startCol, colSpan } = segment;
// 이 색상 그룹 내에서 가능한 가장 낮은 행 찾기
let row = 0;
while (true) {
if (!occupied[row]) {
occupied[row] = Array(7).fill(false);
}
// 이 행에서 해당 컬럼들이 비어있는지 확인
let canPlace = true;
for (let col = startCol; col < startCol + colSpan && col < 7; col++) {
if (occupied[row][col]) {
canPlace = false;
break;
}
}
if (canPlace) {
// 배치
for (let col = startCol; col < startCol + colSpan && col < 7; col++) {
occupied[row][col] = true;
}
rowMap.set(event.id, currentBaseRow + row);
maxRowInGroup = Math.max(maxRowInGroup, row);
break;
}
row++;
if (row > 10) break; // 최대 10행까지
}
});
// 다음 색상 그룹은 이 그룹 아래 행부터 시작
currentBaseRow += maxRowInGroup + 1;
});
return rowMap;
}
/**
* 월간 뷰에서 주 단위로 날짜 분할
*/
export function splitIntoWeeks(days: Date[]): Date[][] {
const weeks: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
return weeks;
}

View File

@@ -13,6 +13,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { printArea } from '@/lib/print-utils';
import type { ReceivingDetail } from './types';
interface Props {
@@ -23,7 +24,7 @@ interface Props {
export function ReceivingReceiptDialog({ open, onOpenChange, detail }: Props) {
const handlePrint = () => {
window.print();
printArea({ title: '입고증 인쇄' });
};
const handleDownload = () => {
@@ -42,8 +43,8 @@ export function ReceivingReceiptDialog({ open, onOpenChange, detail }: Props) {
<DialogTitle> - {detail.orderNo}</DialogTitle>
</VisuallyHidden>
{/* 모달 헤더 - 작업일지 스타일 */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
{/* 모달 헤더 - 작업일지 스타일 (인쇄 시 숨김) */}
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
<div className="flex items-center gap-3">
<span className="font-semibold text-lg"></span>
<span className="text-sm text-muted-foreground">
@@ -73,8 +74,8 @@ export function ReceivingReceiptDialog({ open, onOpenChange, detail }: Props) {
</div>
</div>
{/* 문서 본문 */}
<div className="m-6 p-8 bg-white rounded-lg shadow-sm print:m-0 print:shadow-none">
{/* 문서 본문 (인쇄 시 이 영역만 출력) */}
<div className="print-area m-6 p-8 bg-white rounded-lg shadow-sm">
{/* 제목 */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold"></h2>

View File

@@ -23,6 +23,7 @@ import {
import { ContractDocument } from "./ContractDocument";
import { TransactionDocument } from "./TransactionDocument";
import { PurchaseOrderDocument } from "./PurchaseOrderDocument";
import { printArea } from "@/lib/print-utils";
import { OrderItem } from "../ItemAddDialog";
// 문서 타입
@@ -74,7 +75,7 @@ export function OrderDocumentModal({
};
const handlePrint = () => {
window.print();
printArea({ title: `${getDocumentTitle()} 인쇄` });
};
const handleSharePdf = () => {
@@ -148,8 +149,8 @@ export function OrderDocumentModal({
<DialogTitle>{getDocumentTitle()} </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 */}
<div className="flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold">{getDocumentTitle()} </h2>
<Button
variant="ghost"
@@ -161,8 +162,8 @@ export function OrderDocumentModal({
</Button>
</div>
{/* 버튼 영역 - 고정 */}
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
{/* TODO: 공유 기능 추가 예정 - PDF 다운로드, 이메일, 팩스, 카카오톡 공유 */}
{/* <Button variant="outline" size="sm" onClick={handleSharePdf}>
<FileDown className="h-4 w-4 mr-1" />
@@ -186,8 +187,8 @@ export function OrderDocumentModal({
</Button>
</div>
{/* 문서 영역 - 스크롤 */}
<div className="flex-1 overflow-y-auto bg-gray-100 p-6">
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg">
{renderDocument()}
</div>

View File

@@ -22,7 +22,7 @@ interface StatCardsProps {
export function StatCards({ stats }: StatCardsProps) {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
<div className="flex flex-col sm:flex-row gap-3 md:gap-4">
{stats.map((stat, index) => {
const Icon = stat.icon;
const isClickable = !!stat.onClick;
@@ -30,7 +30,7 @@ export function StatCards({ stats }: StatCardsProps) {
return (
<Card
key={index}
className={`transition-colors ${
className={`flex-1 min-w-0 transition-colors ${
isClickable ? 'cursor-pointer hover:border-primary/50' : ''
} ${
stat.isActive ? 'border-primary bg-primary/5' : ''

View File

@@ -59,6 +59,7 @@ import type { ShipmentDetail as ShipmentDetailType, ShipmentStatus } from './typ
import { ShippingSlip } from './documents/ShippingSlip';
import { TransactionStatement } from './documents/TransactionStatement';
import { DeliveryConfirmation } from './documents/DeliveryConfirmation';
import { printArea } from '@/lib/print-utils';
interface ShipmentDetailProps {
id: string;
@@ -132,8 +133,11 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
// 인쇄
const handlePrint = useCallback(() => {
window.print();
}, []);
const docName = previewDocument === 'shipping' ? '출고증'
: previewDocument === 'transaction' ? '거래명세서'
: '납품확인서';
printArea({ title: `${docName} 인쇄` });
}, [previewDocument]);
// 정보 영역 렌더링
const renderInfoField = (label: string, value: React.ReactNode, className?: string) => (
@@ -417,8 +421,8 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</DialogTitle>
</VisuallyHidden>
{/* 모달 헤더 - 작업일지 스타일 */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
{/* 모달 헤더 - 작업일지 스타일 (인쇄 시 숨김) */}
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
<div className="flex items-center gap-3">
<span className="font-semibold text-lg">
{previewDocument === 'shipping' && '출고증 미리보기'}
@@ -448,8 +452,8 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</div>
</div>
{/* 문서 본문 - 흰색 카드 형태 */}
<div className="m-6 p-6 bg-white rounded-lg shadow-sm">
{/* 문서 본문 - 흰색 카드 형태 (인쇄 시 이 영역만 출력) */}
<div className="print-area m-6 p-6 bg-white rounded-lg shadow-sm">
{previewDocument === 'shipping' && <ShippingSlip data={detail} />}
{previewDocument === 'transaction' && <TransactionStatement data={detail} />}
{previewDocument === 'delivery' && <DeliveryConfirmation data={detail} />}

View File

@@ -16,6 +16,7 @@ import {
} from '@/components/ui/dialog';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Button } from '@/components/ui/button';
import { printArea } from '@/lib/print-utils';
import type { Process } from '@/types/process';
interface ProcessWorkLogPreviewModalProps {
@@ -47,7 +48,7 @@ export function ProcessWorkLogPreviewModal({
process
}: ProcessWorkLogPreviewModalProps) {
const handlePrint = () => {
window.print();
printArea({ title: `${process.workLogTemplate} 인쇄` });
};
const documentCode = getDocumentCode(process.processName);
@@ -75,8 +76,8 @@ export function ProcessWorkLogPreviewModal({
<DialogTitle>{process.workLogTemplate} </DialogTitle>
</VisuallyHidden>
{/* 모달 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
{/* 모달 헤더 (인쇄 시 숨김) */}
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
<div className="flex items-center gap-3">
<span className="font-semibold text-lg">{process.workLogTemplate} </span>
<span className="text-sm text-muted-foreground">({documentCode})</span>
@@ -97,8 +98,8 @@ export function ProcessWorkLogPreviewModal({
</div>
</div>
{/* 문서 본문 */}
<div className="m-6 p-6 bg-white rounded-lg shadow-sm">
{/* 문서 본문 (인쇄 시 이 영역만 출력) */}
<div className="print-area m-6 p-6 bg-white rounded-lg shadow-sm">
{/* 문서 헤더: 로고 + 제목 + 결재라인 */}
<div className="flex border border-gray-300 mb-6">
{/* 좌측: 로고 영역 */}

View File

@@ -15,6 +15,7 @@ import {
} from '@/components/ui/dialog';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { Button } from '@/components/ui/button';
import { printArea } from '@/lib/print-utils';
import type { WorkOrder } from '../ProductionDashboard/types';
import { PROCESS_LABELS } from '../ProductionDashboard/types';
@@ -26,7 +27,7 @@ interface WorkLogModalProps {
export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
const handlePrint = () => {
window.print();
printArea({ title: '작업일지 인쇄' });
};
if (!order) return null;
@@ -67,8 +68,8 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
<VisuallyHidden>
<DialogTitle> - {order.orderNo}</DialogTitle>
</VisuallyHidden>
{/* 모달 헤더 - sam-design 스타일 */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
{/* 모달 헤더 - sam-design 스타일 (인쇄 시 숨김) */}
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
<div className="flex items-center gap-3">
<span className="font-semibold text-lg"></span>
<span className="text-sm text-muted-foreground">
@@ -94,8 +95,8 @@ export function WorkLogModal({ open, onOpenChange, order }: WorkLogModalProps) {
</div>
</div>
{/* 문서 본문 */}
<div className="m-6 p-6 bg-white rounded-lg shadow-sm">
{/* 문서 본문 (인쇄 시 이 영역만 출력) */}
<div className="print-area m-6 p-6 bg-white rounded-lg shadow-sm">
{/* 문서 헤더: 로고 + 제목 + 결재라인 */}
<div className="flex justify-between items-start mb-6 border border-gray-300">
{/* 좌측: 로고 영역 */}

Some files were not shown because too many files have changed in this diff Show More