- 입찰관리: 목록/상세/수정 페이지 및 목업 데이터 - 계약관리: 목록/상세/수정 페이지 구현 - 주문관리: 수주/발주 목록 및 상세 페이지 구현 - 견적 상세 폼: 섹션별 분리 및 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>
346 lines
15 KiB
TypeScript
346 lines
15 KiB
TypeScript
'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>
|
|
);
|
|
} |