feat: 생산/품질/자재/출고/주문 관리 페이지 구현

- 생산관리: 대시보드, 작업지시, 작업실적, 작업자화면
- 품질관리: 검사관리 (리스트/등록/상세)
- 자재관리: 입고관리, 재고현황
- 출고관리: 출하관리 (리스트/등록/상세/수정)
- 주문관리: 수주관리, 생산의뢰
- 기존 컴포넌트 개선: CardTransactionInquiry, VendorDetail, QuoteRegistration
- IntegratedListTemplateV2 개선
- 공통 컴포넌트 분석 문서 추가

🤖 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
2025-12-23 21:13:07 +09:00
parent 2ebcea0255
commit f0e8e51d06
108 changed files with 21156 additions and 84 deletions

View File

@@ -0,0 +1,312 @@
"use client";
/**
* 품목 추가 팝업
*
* 수주 등록 시 품목을 수동으로 추가하는 다이얼로그
*/
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Package } from "lucide-react";
// 품목 타입
export interface OrderItem {
id: string;
itemCode: string; // 품목코드
itemName: string; // 품명
type: string; // 층
symbol: string; // 부호
spec: string; // 규격
width: number; // 가로 (mm)
height: number; // 세로 (mm)
quantity: number; // 수량
unit: string; // 단위
unitPrice: number; // 단가
amount: number; // 금액
guideRailType?: string; // 가이드레일 타입
finish?: string; // 마감
floor?: string; // 층
isFromQuotation?: boolean; // 견적에서 가져온 품목 여부
}
interface ItemAddDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (item: OrderItem) => void;
}
// 가이드레일 타입 옵션
const GUIDE_RAIL_TYPES = [
{ value: "back-120-70", label: "백면형 (120-70)" },
{ value: "back-150-70", label: "백면형 (150-70)" },
{ value: "side-120-70", label: "측면형 (120-70)" },
{ value: "side-150-70", label: "측면형 (150-70)" },
];
// 마감 옵션
const FINISH_OPTIONS = [
{ value: "sus", label: "SUS마감" },
{ value: "powder", label: "분체도장" },
{ value: "paint", label: "일반도장" },
];
// 초기 폼 데이터
const INITIAL_FORM = {
floor: "",
symbol: "",
itemName: "",
width: "",
height: "",
guideRailType: "",
finish: "",
unitPrice: "",
};
export function ItemAddDialog({
open,
onOpenChange,
onAdd,
}: ItemAddDialogProps) {
const [form, setForm] = useState(INITIAL_FORM);
const [errors, setErrors] = useState<Record<string, string>>({});
// 폼 리셋
const resetForm = () => {
setForm(INITIAL_FORM);
setErrors({});
};
// 다이얼로그 닫기 시 리셋
const handleOpenChange = (open: boolean) => {
if (!open) {
resetForm();
}
onOpenChange(open);
};
// 유효성 검사
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.floor.trim()) {
newErrors.floor = "층을 입력해주세요";
}
if (!form.symbol.trim()) {
newErrors.symbol = "도면부호를 입력해주세요";
}
if (!form.width || Number(form.width) <= 0) {
newErrors.width = "가로 치수를 입력해주세요";
}
if (!form.height || Number(form.height) <= 0) {
newErrors.height = "세로 치수를 입력해주세요";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 추가 핸들러
const handleAdd = () => {
if (!validate()) return;
const width = Number(form.width);
const height = Number(form.height);
const unitPrice = Number(form.unitPrice) || 0;
const newItem: OrderItem = {
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
itemCode: `PRD-${Date.now().toString().slice(-4)}`,
itemName: form.itemName || "국민방화스크린세터",
type: "B1",
symbol: form.symbol,
spec: `${width}×${height}`,
width,
height,
quantity: 1,
unit: "EA",
unitPrice,
amount: unitPrice,
guideRailType: form.guideRailType,
finish: form.finish,
floor: form.floor,
};
onAdd(newItem);
handleOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 층 / 도면부호 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="floor">
<span className="text-red-500">*</span>
</Label>
<Input
id="floor"
placeholder="예: 4층"
value={form.floor}
onChange={(e) => setForm({ ...form, floor: e.target.value })}
/>
{errors.floor && (
<p className="text-xs text-red-500">{errors.floor}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="symbol">
<span className="text-red-500">*</span>
</Label>
<Input
id="symbol"
placeholder="예: FSS1"
value={form.symbol}
onChange={(e) => setForm({ ...form, symbol: e.target.value })}
/>
{errors.symbol && (
<p className="text-xs text-red-500">{errors.symbol}</p>
)}
</div>
</div>
{/* 품목명 */}
<div className="space-y-2">
<Label htmlFor="itemName"></Label>
<Input
id="itemName"
placeholder="예: 국민방화스크린세터"
value={form.itemName}
onChange={(e) => setForm({ ...form, itemName: e.target.value })}
/>
</div>
{/* 오픈사이즈 (고객 제공 치수) */}
<div className="space-y-2">
<Label className="text-muted-foreground text-sm">
( )
</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="width">
(mm) <span className="text-red-500">*</span>
</Label>
<Input
id="width"
type="number"
placeholder="예: 7260"
value={form.width}
onChange={(e) => setForm({ ...form, width: e.target.value })}
/>
{errors.width && (
<p className="text-xs text-red-500">{errors.width}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="height">
(mm) <span className="text-red-500">*</span>
</Label>
<Input
id="height"
type="number"
placeholder="예: 2600"
value={form.height}
onChange={(e) => setForm({ ...form, height: e.target.value })}
/>
{errors.height && (
<p className="text-xs text-red-500">{errors.height}</p>
)}
</div>
</div>
</div>
{/* 가이드레일 타입 / 마감 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label> </Label>
<Select
value={form.guideRailType}
onValueChange={(value) =>
setForm({ ...form, guideRailType: value })
}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{GUIDE_RAIL_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={form.finish}
onValueChange={(value) => setForm({ ...form, finish: value })}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{FINISH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 단가 */}
<div className="space-y-2">
<Label htmlFor="unitPrice"> ()</Label>
<Input
id="unitPrice"
type="number"
placeholder="예: 8000000"
value={form.unitPrice}
onChange={(e) => setForm({ ...form, unitPrice: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
</Button>
<Button onClick={handleAdd}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,870 @@
"use client";
/**
* 수주 등록 컴포넌트
*
* - 견적 불러오기 섹션
* - 기본 정보 섹션
* - 수주/배송 정보 섹션
* - 수신처 주소 섹션
* - 비고 섹션
* - 품목 내역 섹션
*/
import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
FileText,
Search,
X,
Plus,
Trash2,
Info,
} from "lucide-react";
import { toast } from "sonner";
import {
ResponsiveFormTemplate,
FormSection,
} from "@/components/templates/ResponsiveFormTemplate";
import { QuotationSelectDialog, QuotationForSelect, QuotationItem } from "./QuotationSelectDialog";
import { ItemAddDialog, OrderItem } from "./ItemAddDialog";
import { formatAmount } from "@/utils/formatAmount";
import { cn } from "@/lib/utils";
// 수주 폼 데이터 타입
export interface OrderFormData {
// 견적 정보
selectedQuotation?: QuotationForSelect;
// 기본 정보
clientId: string;
clientName: string;
siteName: string;
manager: string;
contact: string;
// 수주/배송 정보
expectedShipDate: string;
expectedShipDateUndecided: boolean;
deliveryRequestDate: string;
deliveryRequestDateUndecided: boolean;
deliveryMethod: string;
shippingCost: string;
receiver: string;
receiverContact: string;
// 수신처 주소
zipCode: string;
address: string;
addressDetail: string;
// 비고
remarks: string;
// 품목 내역
items: OrderItem[];
// 금액 정보
subtotal: number;
discountRate: number;
totalAmount: number;
}
// 초기 폼 데이터
const INITIAL_FORM: OrderFormData = {
clientId: "",
clientName: "",
siteName: "",
manager: "",
contact: "",
expectedShipDate: "",
expectedShipDateUndecided: false,
deliveryRequestDate: "",
deliveryRequestDateUndecided: false,
deliveryMethod: "",
shippingCost: "",
receiver: "",
receiverContact: "",
zipCode: "",
address: "",
addressDetail: "",
remarks: "",
items: [],
subtotal: 0,
discountRate: 0,
totalAmount: 0,
};
// 배송방식 옵션
const DELIVERY_METHODS = [
{ value: "direct", label: "직접배차" },
{ value: "pickup", label: "상차" },
{ value: "courier", label: "택배" },
];
// 운임비용 옵션
const SHIPPING_COSTS = [
{ value: "free", label: "무료" },
{ value: "prepaid", label: "선불" },
{ value: "collect", label: "착불" },
{ value: "negotiable", label: "협의" },
];
// 샘플 발주처 데이터
const SAMPLE_CLIENTS = [
{ id: "C001", name: "태영건설(주)" },
{ id: "C002", name: "현대건설(주)" },
{ id: "C003", name: "GS건설(주)" },
{ id: "C004", name: "대우건설(주)" },
{ id: "C005", name: "포스코건설" },
];
interface OrderRegistrationProps {
onBack: () => void;
onSave: (formData: OrderFormData) => Promise<void>;
initialData?: Partial<OrderFormData>;
isEditMode?: boolean;
}
// 필드별 에러 타입
interface FieldErrors {
clientName?: string;
siteName?: string;
deliveryRequestDate?: string;
receiver?: string;
receiverContact?: string;
items?: string;
}
// 필드명 한글 매핑
const FIELD_NAME_MAP: Record<string, string> = {
clientName: "발주처",
siteName: "현장명",
deliveryRequestDate: "납품요청일",
receiver: "수신(반장/업체)",
receiverContact: "수신처 연락처",
items: "품목 내역",
};
export function OrderRegistration({
onBack,
onSave,
initialData,
isEditMode = false,
}: OrderRegistrationProps) {
const [form, setForm] = useState<OrderFormData>({
...INITIAL_FORM,
...initialData,
});
const [isQuotationDialogOpen, setIsQuotationDialogOpen] = useState(false);
const [isItemDialogOpen, setIsItemDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
// 금액 계산
useEffect(() => {
const subtotal = form.items.reduce((sum, item) => sum + item.amount, 0);
const discountAmount = subtotal * (form.discountRate / 100);
const totalAmount = subtotal - discountAmount;
setForm((prev) => ({
...prev,
subtotal,
totalAmount,
}));
}, [form.items, form.discountRate]);
// 견적 선택 핸들러
const handleQuotationSelect = (quotation: QuotationForSelect) => {
// 견적 정보로 폼 자동 채우기 (견적에서 가져온 품목은 삭제 불가)
const items: OrderItem[] = (quotation.items || []).map((qi: QuotationItem) => ({
id: qi.id,
itemCode: qi.itemCode,
itemName: qi.itemName,
type: qi.type,
symbol: qi.symbol,
spec: qi.spec,
width: 0,
height: 0,
quantity: qi.quantity,
unit: qi.unit,
unitPrice: qi.unitPrice,
amount: qi.amount,
isFromQuotation: true, // 견적에서 가져온 품목 표시
}));
setForm((prev) => ({
...prev,
selectedQuotation: quotation,
clientName: quotation.client,
siteName: quotation.siteName,
manager: quotation.manager || "",
contact: quotation.contact || "",
items,
}));
toast.success("견적 정보가 불러와졌습니다.");
};
// 견적 해제 핸들러
const handleClearQuotation = () => {
setForm((prev) => ({
...prev,
selectedQuotation: undefined,
// 기본 정보는 유지하고 품목만 초기화할지, 전체 초기화할지 선택 가능
items: [],
}));
};
// 품목 추가 핸들러
const handleAddItem = (item: OrderItem) => {
setForm((prev) => ({
...prev,
items: [...prev.items, item],
}));
// 품목 에러 초기화
setFieldErrors((prev) => {
if (prev.items) {
const { items: _, ...rest } = prev;
return rest;
}
return prev;
});
toast.success("품목이 추가되었습니다.");
};
// 품목 삭제 핸들러
const handleRemoveItem = (itemId: string) => {
setForm((prev) => ({
...prev,
items: prev.items.filter((item) => item.id !== itemId),
}));
};
// 품목 수량 변경 핸들러
const handleQuantityChange = (itemId: string, quantity: number) => {
setForm((prev) => ({
...prev,
items: prev.items.map((item) =>
item.id === itemId
? { ...item, quantity, amount: item.unitPrice * quantity }
: item
),
}));
};
// 유효성 검사 함수
const validateForm = useCallback((): FieldErrors => {
const errors: FieldErrors = {};
if (!form.clientName.trim()) {
errors.clientName = "발주처를 선택해주세요.";
}
if (!form.siteName.trim()) {
errors.siteName = "현장명을 입력해주세요.";
}
if (!form.deliveryRequestDate && !form.deliveryRequestDateUndecided) {
errors.deliveryRequestDate = "납품요청일을 입력하거나 '미정'을 선택해주세요.";
}
if (!form.receiver.trim()) {
errors.receiver = "수신자명을 입력해주세요.";
}
if (!form.receiverContact.trim()) {
errors.receiverContact = "연락처를 입력해주세요.";
}
if (form.items.length === 0) {
errors.items = "최소 1개 이상의 품목을 추가해주세요.";
}
return errors;
}, [form]);
// 필드 에러 초기화 (필드 값 변경 시)
const clearFieldError = useCallback((field: keyof FieldErrors) => {
setFieldErrors((prev) => {
if (prev[field]) {
const { [field]: _, ...rest } = prev;
return rest;
}
return prev;
});
}, []);
// 저장 핸들러
const handleSave = async () => {
// 유효성 검사
const errors = validateForm();
setFieldErrors(errors);
const errorCount = Object.keys(errors).length;
if (errorCount > 0) {
toast.error(`입력 내용을 확인해주세요. (${errorCount}개 오류)`);
return;
}
setIsSaving(true);
try {
await onSave(form);
} finally {
setIsSaving(false);
}
};
return (
<>
<ResponsiveFormTemplate
title={isEditMode ? "수주 수정" : "수주 등록"}
description="견적을 수주로 전환하거나 새 수주를 등록합니다"
icon={FileText}
onSave={handleSave}
onCancel={onBack}
saveLabel="저장"
cancelLabel="취소"
saveLoading={isSaving}
saveDisabled={isSaving}
>
{/* Validation 에러 Alert */}
{Object.keys(fieldErrors).length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({Object.keys(fieldErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(fieldErrors).map(([field, error]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {error}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 견적 불러오기 섹션 */}
<FormSection title="견적 불러오기" icon={Search}>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Info className="h-4 w-4" />
</div>
{form.selectedQuotation ? (
<div className="p-4 border rounded-lg bg-muted/30">
<div className="flex items-start justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
{form.selectedQuotation.quoteNumber}
</code>
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">
{form.selectedQuotation.grade} ()
</Badge>
</div>
<div className="text-sm">
<span className="font-medium">{form.selectedQuotation.client}</span>
<span className="text-muted-foreground mx-2">/</span>
<span>{form.selectedQuotation.siteName}</span>
<span className="text-muted-foreground mx-2">/</span>
<span className="text-green-600 font-medium">
{formatAmount(form.selectedQuotation.amount)}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClearQuotation}
>
<X className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
) : (
<Button
variant="outline"
onClick={() => setIsQuotationDialogOpen(true)}
>
<Search className="h-4 w-4 mr-2" />
</Button>
)}
</div>
</FormSection>
{/* 기본 정보 섹션 */}
<FormSection title="기본 정보" icon={FileText}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Select
value={form.clientId}
onValueChange={(value) => {
const client = SAMPLE_CLIENTS.find((c) => c.id === value);
setForm((prev) => ({
...prev,
clientId: value,
clientName: client?.name || "",
}));
clearFieldError("clientName");
}}
>
<SelectTrigger className={cn(fieldErrors.clientName && "border-red-500")}>
<SelectValue placeholder="발주처 선택">
{form.clientName || "발주처 선택"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{SAMPLE_CLIENTS.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldErrors.clientName && (
<p className="text-sm text-red-500">{fieldErrors.clientName}</p>
)}
</div>
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Input
placeholder="현장명 입력"
value={form.siteName}
onChange={(e) => {
setForm((prev) => ({ ...prev, siteName: e.target.value }));
clearFieldError("siteName");
}}
className={cn(fieldErrors.siteName && "border-red-500")}
/>
{fieldErrors.siteName && (
<p className="text-sm text-red-500">{fieldErrors.siteName}</p>
)}
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="담당자명 입력"
value={form.manager}
onChange={(e) =>
setForm((prev) => ({ ...prev, manager: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="010-0000-0000"
value={form.contact}
onChange={(e) =>
setForm((prev) => ({ ...prev, contact: e.target.value }))
}
/>
</div>
</div>
</FormSection>
{/* 수주/배송 정보 섹션 */}
<FormSection title="수주/배송 정보">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 출고예정일 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-4">
<Input
type="date"
value={form.expectedShipDate}
onChange={(e) =>
setForm((prev) => ({
...prev,
expectedShipDate: e.target.value,
}))
}
disabled={form.expectedShipDateUndecided}
className="flex-1"
/>
<div className="flex items-center gap-2">
<Checkbox
id="expectedShipDateUndecided"
checked={form.expectedShipDateUndecided}
onCheckedChange={(checked) =>
setForm((prev) => ({
...prev,
expectedShipDateUndecided: checked as boolean,
expectedShipDate: checked ? "" : prev.expectedShipDate,
}))
}
/>
<Label
htmlFor="expectedShipDateUndecided"
className="text-sm font-normal"
>
</Label>
</div>
</div>
</div>
{/* 납품요청일 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<div className="flex items-center gap-4">
<Input
type="date"
value={form.deliveryRequestDate}
onChange={(e) => {
setForm((prev) => ({
...prev,
deliveryRequestDate: e.target.value,
}));
clearFieldError("deliveryRequestDate");
}}
disabled={form.deliveryRequestDateUndecided}
className={cn("flex-1", fieldErrors.deliveryRequestDate && "border-red-500")}
/>
<div className="flex items-center gap-2">
<Checkbox
id="deliveryRequestDateUndecided"
checked={form.deliveryRequestDateUndecided}
onCheckedChange={(checked) => {
setForm((prev) => ({
...prev,
deliveryRequestDateUndecided: checked as boolean,
deliveryRequestDate: checked
? ""
: prev.deliveryRequestDate,
}));
if (checked) clearFieldError("deliveryRequestDate");
}}
/>
<Label
htmlFor="deliveryRequestDateUndecided"
className="text-sm font-normal"
>
</Label>
</div>
</div>
{fieldErrors.deliveryRequestDate && (
<p className="text-sm text-red-500">{fieldErrors.deliveryRequestDate}</p>
)}
</div>
{/* 배송방식 */}
<div className="space-y-2">
<Label></Label>
<Select
value={form.deliveryMethod}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, deliveryMethod: value }))
}
>
<SelectTrigger>
<SelectValue placeholder="배송방식 선택" />
</SelectTrigger>
<SelectContent>
{DELIVERY_METHODS.map((method) => (
<SelectItem key={method.value} value={method.value}>
{method.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 운임비용 */}
<div className="space-y-2">
<Label></Label>
<Select
value={form.shippingCost}
onValueChange={(value) =>
setForm((prev) => ({ ...prev, shippingCost: value }))
}
>
<SelectTrigger>
<SelectValue placeholder="운임비용 선택" />
</SelectTrigger>
<SelectContent>
{SHIPPING_COSTS.map((cost) => (
<SelectItem key={cost.value} value={cost.value}>
{cost.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 수신(반장/업체) */}
<div className="space-y-2">
<Label>
(/) <span className="text-red-500">*</span>
</Label>
<Input
placeholder="수신자명 입력"
value={form.receiver}
onChange={(e) => {
setForm((prev) => ({ ...prev, receiver: e.target.value }));
clearFieldError("receiver");
}}
className={cn(fieldErrors.receiver && "border-red-500")}
/>
{fieldErrors.receiver && (
<p className="text-sm text-red-500">{fieldErrors.receiver}</p>
)}
</div>
{/* 수신처 연락처 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Input
placeholder="010-0000-0000"
value={form.receiverContact}
onChange={(e) => {
setForm((prev) => ({
...prev,
receiverContact: e.target.value,
}));
clearFieldError("receiverContact");
}}
className={cn(fieldErrors.receiverContact && "border-red-500")}
/>
{fieldErrors.receiverContact && (
<p className="text-sm text-red-500">{fieldErrors.receiverContact}</p>
)}
</div>
</div>
</FormSection>
{/* 수신처 주소 섹션 */}
<FormSection title="수신처 주소">
<div className="space-y-4">
<div className="flex gap-2">
<Input
placeholder="우편번호"
value={form.zipCode}
onChange={(e) =>
setForm((prev) => ({ ...prev, zipCode: e.target.value }))
}
className="w-32"
/>
<Button variant="outline" type="button">
</Button>
</div>
<Input
placeholder="기본 주소"
value={form.address}
onChange={(e) =>
setForm((prev) => ({ ...prev, address: e.target.value }))
}
/>
<Input
placeholder="상세 주소 입력"
value={form.addressDetail}
onChange={(e) =>
setForm((prev) => ({ ...prev, addressDetail: e.target.value }))
}
/>
</div>
</FormSection>
{/* 비고 섹션 */}
<FormSection title="비고">
<Textarea
placeholder="특이사항을 입력하세요"
value={form.remarks}
onChange={(e) =>
setForm((prev) => ({ ...prev, remarks: e.target.value }))
}
rows={4}
/>
</FormSection>
{/* 품목 내역 섹션 */}
<FormSection title="품목 내역">
<div className="space-y-4">
{/* 품목 에러 메시지 */}
{fieldErrors.items && (
<p className="text-sm text-red-500">{fieldErrors.items}</p>
)}
{/* 품목 테이블 */}
<div className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{form.items.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="text-center py-8 text-muted-foreground">
. .
</TableCell>
</TableRow>
) : (
form.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.itemCode}
</code>
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.type || "-"}</TableCell>
<TableCell>{item.symbol || "-"}</TableCell>
<TableCell>{item.spec}</TableCell>
<TableCell className="text-center">
<Input
type="number"
min={1}
value={item.quantity}
onChange={(e) =>
handleQuantityChange(
item.id,
Number(e.target.value) || 1
)
}
className="w-16 text-center"
/>
</TableCell>
<TableCell className="text-center">{item.unit}</TableCell>
<TableCell className="text-right">
{formatAmount(item.unitPrice)}
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.amount)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveItem(item.id)}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 품목 추가 버튼 */}
<Button
variant="outline"
onClick={() => setIsItemDialogOpen(true)}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
{/* 합계 */}
<div className="flex flex-col items-end gap-2 pt-4 border-t">
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">:</span>
<span className="w-32 text-right">
{formatAmount(form.subtotal)}
</span>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">(%):</span>
<Input
type="number"
min={0}
max={100}
value={form.discountRate}
onChange={(e) =>
setForm((prev) => ({
...prev,
discountRate: Number(e.target.value) || 0,
}))
}
className="w-20 text-right"
/>
</div>
<div className="flex items-center gap-4 text-lg font-semibold">
<span>:</span>
<span className="w-32 text-right text-green-600">
{formatAmount(form.totalAmount)}
</span>
</div>
</div>
</div>
</FormSection>
</ResponsiveFormTemplate>
{/* 견적 선택 팝업 */}
<QuotationSelectDialog
open={isQuotationDialogOpen}
onOpenChange={setIsQuotationDialogOpen}
onSelect={handleQuotationSelect}
selectedId={form.selectedQuotation?.id}
/>
{/* 품목 추가 팝업 */}
<ItemAddDialog
open={isItemDialogOpen}
onOpenChange={setIsItemDialogOpen}
onAdd={handleAddItem}
/>
</>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
/**
* 견적 선택 팝업
*
* 확정된 견적 목록에서 수주 전환할 견적을 선택하는 다이얼로그
*/
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Search, FileText, Check } from "lucide-react";
import { formatAmount } from "@/utils/formatAmount";
import { cn } from "@/lib/utils";
// 견적 타입
export interface QuotationForSelect {
id: string;
quoteNumber: string; // KD-PR-XXXXXX-XX
grade: string; // A(우량), B(관리), C(주의)
client: string; // 발주처
siteName: string; // 현장명
amount: number; // 총 금액
itemCount: number; // 품목 수
registrationDate: string; // 견적일
manager?: string; // 담당자
contact?: string; // 연락처
items?: QuotationItem[]; // 품목 내역
}
export interface QuotationItem {
id: string;
itemCode: string;
itemName: string;
type: string; // 종
symbol: string; // 부호
spec: string; // 규격
quantity: number;
unit: string;
unitPrice: number;
amount: number;
}
interface QuotationSelectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (quotation: QuotationForSelect) => void;
selectedId?: string;
}
// 샘플 견적 데이터 (실제 구현에서는 API 연동)
const SAMPLE_QUOTATIONS: QuotationForSelect[] = [
{
id: "QT-001",
quoteNumber: "KD-PR-251210-01",
grade: "A",
client: "태영건설(주)",
siteName: "데시앙 동탄 파크뷰",
amount: 38800000,
itemCount: 5,
registrationDate: "2024-12-10",
manager: "김철수",
contact: "010-1234-5678",
items: [
{ id: "1", itemCode: "PRD-001", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS1", spec: "7260×2600", quantity: 2, unit: "EA", unitPrice: 8000000, amount: 16000000 },
{ id: "2", itemCode: "PRD-002", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "5000×2400", quantity: 3, unit: "EA", unitPrice: 7600000, amount: 22800000 },
],
},
{
id: "QT-002",
quoteNumber: "KD-PR-251211-02",
grade: "A",
client: "현대건설(주)",
siteName: "힐스테이트 판교역",
amount: 52500000,
itemCount: 8,
registrationDate: "2024-12-11",
manager: "이영희",
contact: "010-2345-6789",
items: [
{ id: "1", itemCode: "PRD-003", itemName: "국민방화스크린세터", type: "B2", symbol: "FSS1", spec: "6000×3000", quantity: 4, unit: "EA", unitPrice: 9500000, amount: 38000000 },
{ id: "2", itemCode: "PRD-004", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "4500×2500", quantity: 2, unit: "EA", unitPrice: 7250000, amount: 14500000 },
],
},
{
id: "QT-003",
quoteNumber: "KD-PR-251208-03",
grade: "B",
client: "GS건설(주)",
siteName: "자이 강남센터",
amount: 45000000,
itemCount: 6,
registrationDate: "2024-12-08",
manager: "박민수",
contact: "010-3456-7890",
items: [],
},
{
id: "QT-004",
quoteNumber: "KD-PR-251205-04",
grade: "B",
client: "대우건설(주)",
siteName: "푸르지오 송도",
amount: 28900000,
itemCount: 4,
registrationDate: "2024-12-05",
manager: "최지원",
contact: "010-4567-8901",
items: [],
},
{
id: "QT-005",
quoteNumber: "KD-PR-251201-05",
grade: "A",
client: "포스코건설",
siteName: "더샵 분당센트럴",
amount: 62000000,
itemCount: 10,
registrationDate: "2024-12-01",
manager: "정수민",
contact: "010-5678-9012",
items: [],
},
];
// 등급 배지 컴포넌트
function GradeBadge({ grade }: { grade: string }) {
const config: Record<string, { label: string; className: string }> = {
A: { label: "A (우량)", className: "bg-green-100 text-green-700 border-green-200" },
B: { label: "B (관리)", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
C: { label: "C (주의)", className: "bg-red-100 text-red-700 border-red-200" },
};
const cfg = config[grade] || config.B;
return (
<Badge variant="outline" className={cn("text-xs", cfg.className)}>
{cfg.label}
</Badge>
);
}
export function QuotationSelectDialog({
open,
onOpenChange,
onSelect,
selectedId,
}: QuotationSelectDialogProps) {
const [searchTerm, setSearchTerm] = useState("");
const [quotations] = useState<QuotationForSelect[]>(SAMPLE_QUOTATIONS);
// 검색 필터링
const filteredQuotations = quotations.filter((q) => {
const searchLower = searchTerm.toLowerCase();
return (
!searchTerm ||
q.quoteNumber.toLowerCase().includes(searchLower) ||
q.client.toLowerCase().includes(searchLower) ||
q.siteName.toLowerCase().includes(searchLower)
);
});
// 다이얼로그 열릴 때 검색어 초기화
useEffect(() => {
if (open) {
setSearchTerm("");
}
}, [open]);
const handleSelect = (quotation: QuotationForSelect) => {
onSelect(quotation);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
{/* 검색창 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="견적번호, 거래처, 현장명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
{/* 안내 문구 */}
<div className="text-sm text-muted-foreground">
{filteredQuotations.length} ( )
</div>
{/* 견적 목록 */}
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
{filteredQuotations.map((quotation) => (
<div
key={quotation.id}
onClick={() => handleSelect(quotation)}
className={cn(
"p-4 border rounded-lg cursor-pointer transition-colors",
"hover:bg-muted/50 hover:border-primary/50",
selectedId === quotation.id && "border-primary bg-primary/5"
)}
>
{/* 상단: 견적번호 + 등급 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
{quotation.quoteNumber}
</code>
<GradeBadge grade={quotation.grade} />
</div>
{selectedId === quotation.id && (
<Check className="h-5 w-5 text-primary" />
)}
</div>
{/* 발주처 */}
<div className="font-medium text-base mb-1">
{quotation.client}
</div>
{/* 현장명 + 금액 */}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
[{quotation.siteName}]
</span>
<span className="font-medium text-green-600">
{formatAmount(quotation.amount)}
</span>
</div>
{/* 품목 수 */}
<div className="text-xs text-muted-foreground mt-1 text-right">
{quotation.itemCount}
</div>
</div>
))}
{filteredQuotations.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
.
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
/**
* 계약서 문서 컴포넌트
* - 스크린샷 형식 + 지출결의서 디자인 스타일
*/
import { formatAmount } from "@/utils/formatAmount";
import { OrderItem } from "@/components/orders";
interface ContractDocumentProps {
orderNumber: string;
orderDate: string;
client: string;
clientBusinessNumber?: string;
clientCeo?: string;
clientContact?: string;
clientAddress?: string;
companyName?: string;
companyCeo?: string;
companyBusinessNumber?: string;
companyContact?: string;
companyAddress?: string;
items: OrderItem[];
subtotal: number;
discountRate: number;
totalAmount: number;
remarks?: string;
}
export function ContractDocument({
orderNumber,
orderDate,
client,
clientBusinessNumber = "123-45-67890",
clientCeo = "대표자",
clientContact = "02-1234-5678",
clientAddress = "서울시 강남구",
companyName = "(주)케이디산업",
companyCeo = "김대표",
companyBusinessNumber = "111-22-33333",
companyContact = "02-9999-8888",
companyAddress = "경기도 화성시 케이디로 123",
items,
subtotal,
discountRate,
totalAmount,
remarks,
}: ContractDocumentProps) {
const discountAmount = Math.round(subtotal * (discountRate / 100));
const afterDiscount = subtotal - discountAmount;
const vat = Math.round(afterDiscount * 0.1);
const finalTotal = afterDiscount + vat;
return (
<div className="bg-white p-8 min-h-full">
{/* 제목 */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold tracking-widest mb-2"> </h1>
<p className="text-sm text-gray-600">
: {orderNumber} | : {orderDate}
</p>
</div>
{/* 제품명 */}
<div className="border border-gray-300 mb-4">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 text-center text-sm">
()
</div>
</div>
{/* 수주물목 테이블 */}
<div className="border border-gray-300 mb-4">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
( )
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100 border-b border-gray-300">
<th className="p-2 text-center font-medium border-r border-gray-300 w-24"></th>
<th className="p-2 text-left font-medium border-r border-gray-300"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-28"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-16"></th>
<th className="p-2 text-center font-medium w-16"></th>
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item) => (
<tr key={item.id} className="border-b border-gray-300">
<td className="p-2 text-center border-r border-gray-300">{item.itemCode}</td>
<td className="p-2 border-r border-gray-300">{item.itemName}</td>
<td className="p-2 text-center border-r border-gray-300">{item.spec}</td>
<td className="p-2 text-center border-r border-gray-300">{item.quantity}</td>
<td className="p-2 text-center">{item.unit}</td>
</tr>
))
) : (
<tr className="border-b border-gray-300">
<td colSpan={5} className="p-4 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 발주처/당사 정보 */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 space-y-1 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{client}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>-</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{clientCeo}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{clientContact}</span>
</div>
</div>
</div>
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 space-y-1 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{companyName}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{companyCeo}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{companyBusinessNumber}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{companyContact}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{companyAddress}</span>
</div>
</div>
</div>
</div>
{/* 총 계약 금액 */}
<div className="border border-gray-300 p-6 text-center mb-4">
<p className="text-sm text-gray-600 mb-2"> </p>
<p className="text-3xl font-bold">
{formatAmount(finalTotal)}
</p>
<p className="text-sm text-gray-600 mt-1">( )</p>
</div>
{/* 금액 계산 */}
<div className="border border-gray-300 mb-4">
<table className="w-full text-sm">
<tbody>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300 w-32"></td>
<td className="p-2 text-right border-r border-gray-300">{formatAmount(subtotal)}</td>
<td className="p-2 bg-gray-100 border-r border-gray-300 w-32"></td>
<td className="p-2 text-right">{discountRate}%</td>
</tr>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"></td>
<td className="p-2 text-right border-r border-gray-300 text-red-600">-{formatAmount(discountAmount)}</td>
<td className="p-2 bg-gray-100 border-r border-gray-300"> </td>
<td className="p-2 text-right">{formatAmount(afterDiscount)}</td>
</tr>
<tr>
<td className="p-2 bg-gray-100 border-r border-gray-300">(10%)</td>
<td className="p-2 text-right border-r border-gray-300">{formatAmount(vat)}</td>
<td className="p-2 bg-gray-100 border-r border-gray-300 font-medium"></td>
<td className="p-2 text-right font-semibold">{formatAmount(finalTotal)}</td>
</tr>
</tbody>
</table>
</div>
{/* 특이사항 */}
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 min-h-[60px] text-sm">
{remarks || "-"}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
"use client";
/**
* 수주 문서 모달 컴포넌트
* - 계약서, 거래명세서, 발주서를 모달 형태로 표시
*/
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
X as XIcon,
Printer,
Share2,
FileDown,
Mail,
Phone,
} from "lucide-react";
import { ContractDocument } from "./ContractDocument";
import { TransactionDocument } from "./TransactionDocument";
import { PurchaseOrderDocument } from "./PurchaseOrderDocument";
import { OrderItem } from "../ItemAddDialog";
// 문서 타입
export type OrderDocumentType = "contract" | "transaction" | "purchaseOrder";
// 문서 데이터 인터페이스
export interface OrderDocumentData {
lotNumber: string;
orderDate: string;
client: string;
siteName: string;
manager: string;
managerContact: string;
deliveryRequestDate: string;
expectedShipDate: string;
deliveryMethod: string;
address: string;
items: OrderItem[];
subtotal: number;
discountRate: number;
totalAmount: number;
remarks?: string;
}
interface OrderDocumentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: OrderDocumentType;
data: OrderDocumentData;
}
export function OrderDocumentModal({
open,
onOpenChange,
documentType,
data,
}: OrderDocumentModalProps) {
const getDocumentTitle = () => {
switch (documentType) {
case "contract":
return "계약서";
case "transaction":
return "거래명세서";
case "purchaseOrder":
return "발주서";
default:
return "문서";
}
};
const handlePrint = () => {
window.print();
};
const handleSharePdf = () => {
console.log("PDF 다운로드");
};
const handleShareEmail = () => {
console.log("이메일 공유");
};
const handleShareFax = () => {
console.log("팩스 전송");
};
const handleShareKakao = () => {
console.log("카카오톡 공유");
};
const renderDocument = () => {
switch (documentType) {
case "contract":
return (
<ContractDocument
orderNumber={data.lotNumber}
orderDate={data.orderDate}
client={data.client}
items={data.items}
subtotal={data.subtotal}
discountRate={data.discountRate}
totalAmount={data.totalAmount}
remarks={data.remarks}
/>
);
case "transaction":
return (
<TransactionDocument
orderNumber={data.lotNumber}
orderDate={data.orderDate}
client={data.client}
items={data.items}
subtotal={data.subtotal}
discountRate={data.discountRate}
totalAmount={data.totalAmount}
/>
);
case "purchaseOrder":
return (
<PurchaseOrderDocument
orderNumber={data.lotNumber}
client={data.client}
siteName={data.siteName}
manager={data.manager}
managerContact={data.managerContact}
deliveryRequestDate={data.deliveryRequestDate}
expectedShipDate={data.expectedShipDate}
deliveryMethod={data.deliveryMethod}
address={data.address}
items={data.items}
remarks={data.remarks}
/>
);
default:
return null;
}
};
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>{getDocumentTitle()} </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 */}
<div className="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"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - 고정 */}
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleSharePdf}>
<FileDown className="h-4 w-4 mr-1" />
PDF
</Button>
<Button variant="outline" size="sm" onClick={handleShareEmail}>
<Mail className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleShareFax}>
<Phone className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleShareKakao}>
<Share2 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="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>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
/**
* 발주서 문서 컴포넌트
* - 스크린샷 형식 + 지출결의서 디자인 스타일
*/
import { OrderItem } from "@/components/orders";
interface PurchaseOrderDocumentProps {
orderNumber: string;
client: string;
siteName: string;
manager?: string;
managerContact?: string;
deliveryRequestDate: string;
expectedShipDate?: string;
deliveryMethod?: string;
address: string;
orderDate?: string;
installationCount?: number;
items: OrderItem[];
remarks?: string;
}
export function PurchaseOrderDocument({
orderNumber,
client,
siteName,
manager = "-",
managerContact = "010-0123-4567",
deliveryRequestDate,
expectedShipDate = "-",
deliveryMethod = "상차",
address,
orderDate = new Date().toISOString().split("T")[0],
installationCount = 3,
items,
remarks,
}: PurchaseOrderDocumentProps) {
return (
<div className="bg-white p-8 min-h-full">
{/* 헤더: 제목 + 로트번호/결재란 */}
<div className="flex justify-between items-start mb-6">
<h1 className="text-2xl font-bold tracking-widest"> </h1>
<div className="text-right">
<div className="flex items-center justify-end gap-2 mb-2 text-sm">
<span className="text-gray-600"></span>
<span className="font-medium">{orderNumber}</span>
</div>
<table className="border border-gray-300 text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="border-r border-gray-300 px-2 py-1"></th>
<th className="px-2 py-1"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
<td className="border-r border-t border-gray-300 h-8 w-10"></td>
<td className="border-t border-gray-300 h-8 w-10"></td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 신청업체 */}
<div className="border border-gray-300 mb-4">
<table className="w-full text-sm">
<tbody>
<tr>
<td rowSpan={2} className="bg-gray-100 border-r border-b border-gray-300 p-2 text-center font-medium w-20">
</td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20"></td>
<td className="border-r border-b border-gray-300 p-2">{client}</td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20"></td>
<td className="border-b border-gray-300 p-2">{orderDate}</td>
</tr>
<tr>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2"></td>
<td className="border-r border-b border-gray-300 p-2">{manager}</td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2"></td>
<td className="border-b border-gray-300 p-2">{managerContact}</td>
</tr>
<tr>
<td className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium"></td>
<td className="bg-gray-100 border-r border-gray-300 p-2">FAX</td>
<td className="border-r border-gray-300 p-2">-</td>
<td className="bg-gray-100 border-r border-gray-300 p-2"><br/>()</td>
<td className="border-gray-300 p-2">{installationCount}</td>
</tr>
</tbody>
</table>
</div>
{/* 신청내용 */}
<div className="border border-gray-300 mb-4">
<table className="w-full text-sm">
<tbody>
<tr>
<td rowSpan={3} className="bg-gray-100 border-r border-gray-300 p-2 text-center font-medium w-20">
</td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20"></td>
<td colSpan={3} className="border-b border-gray-300 p-2">{siteName}</td>
</tr>
<tr>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2"><br/></td>
<td className="border-r border-b border-gray-300 p-2">{deliveryRequestDate}</td>
<td className="bg-gray-100 border-r border-b border-gray-300 p-2 w-20"></td>
<td className="border-b border-gray-300 p-2">{deliveryMethod}</td>
</tr>
<tr>
<td className="bg-gray-100 border-r border-gray-300 p-2"></td>
<td className="border-r border-gray-300 p-2">{expectedShipDate}</td>
<td className="bg-gray-100 border-r border-gray-300 p-2"></td>
<td className="border-gray-300 p-2">{address}</td>
</tr>
</tbody>
</table>
</div>
{/* 부자재 */}
<div className="mb-4">
<p className="text-sm font-medium mb-2"> </p>
<div className="border border-gray-300">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100 border-b border-gray-300">
<th className="p-2 text-center font-medium border-r border-gray-300 w-16"></th>
<th className="p-2 text-left font-medium border-r border-gray-300"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-20"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-24">(mm)</th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-16"></th>
<th className="p-2 text-center font-medium w-24"></th>
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item, index) => (
<tr key={item.id} className="border-b border-gray-300">
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
<td className="p-2 border-r border-gray-300">{item.itemName}</td>
<td className="p-2 text-center border-r border-gray-300">{item.spec}</td>
<td className="p-2 text-center border-r border-gray-300">
{item.width ? `${item.width}` : "-"}
</td>
<td className="p-2 text-center border-r border-gray-300">{item.quantity}</td>
<td className="p-2 text-center">{item.symbol || "-"}</td>
</tr>
))
) : (
<tr className="border-b border-gray-300">
<td colSpan={6} className="p-4 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 특이사항 */}
<div className="mb-4">
<p className="text-sm font-medium mb-2"> </p>
<div className="border border-gray-300 p-3 min-h-[50px] text-sm">
{remarks || "-"}
</div>
</div>
{/* 유의사항 */}
<div className="mb-4">
<p className="text-sm font-medium mb-2"> </p>
<div className="border border-gray-300 p-3 text-sm">
<ul className="space-y-1">
<li> .</li>
<li> .</li>
<li> .</li>
</ul>
</div>
</div>
{/* 문의 */}
<div className="text-center text-sm text-gray-600">
문의: 홍길동 | 010-1234-5678
</div>
</div>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
/**
* 거래명세서 문서 컴포넌트
* - 스크린샷 형식 + 지출결의서 디자인 스타일
*/
import { formatAmount } from "@/utils/formatAmount";
import { OrderItem } from "@/components/orders";
interface TransactionDocumentProps {
orderNumber: string;
orderDate: string;
client: string;
clientBusinessNumber?: string;
clientCeo?: string;
clientContact?: string;
clientAddress?: string;
clientSiteName?: string;
companyName?: string;
companyCeo?: string;
companyBusinessNumber?: string;
companyContact?: string;
companyAddress?: string;
items: OrderItem[];
subtotal: number;
discountRate: number;
totalAmount: number;
}
export function TransactionDocument({
orderNumber,
orderDate,
client,
clientBusinessNumber = "123-45-67890",
clientCeo = "대표자",
clientContact = "010-0123-4567",
clientAddress = "서울시 강남구",
clientSiteName = "-",
companyName = "(주)케이디산업",
companyCeo = "홍길동",
companyBusinessNumber = "123-45-67890",
companyContact = "02-1234-5678",
companyAddress = "서울 강남구 테헤란로 123",
items,
subtotal,
discountRate,
totalAmount,
}: TransactionDocumentProps) {
const discountAmount = Math.round(subtotal * (discountRate / 100));
const afterDiscount = subtotal - discountAmount;
const vat = Math.round(afterDiscount * 0.1);
const finalTotal = afterDiscount + vat;
return (
<div className="bg-white p-8 min-h-full">
{/* 제목 */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold tracking-widest mb-2"> </h1>
<p className="text-sm text-gray-600">
: {orderNumber} | : {orderDate}
</p>
</div>
{/* 공급자/공급받는자 정보 */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 space-y-1 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{companyName}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{companyCeo}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{companyBusinessNumber}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{companyAddress}</span>
</div>
</div>
</div>
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-3 space-y-1 text-sm">
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{client}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{clientCeo}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{clientContact}</span>
</div>
<div className="flex">
<span className="w-20 text-gray-600"></span>
<span>{clientSiteName}</span>
</div>
</div>
</div>
</div>
{/* 품목내역 */}
<div className="border border-gray-300 mb-4">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100 border-b border-gray-300">
<th className="p-2 text-center font-medium border-r border-gray-300 w-12"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-20"></th>
<th className="p-2 text-left font-medium border-r border-gray-300"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-24"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-12"></th>
<th className="p-2 text-center font-medium border-r border-gray-300 w-12"></th>
<th className="p-2 text-right font-medium border-r border-gray-300 w-24"></th>
<th className="p-2 text-right font-medium w-24"></th>
</tr>
</thead>
<tbody>
{items.length > 0 ? (
items.map((item, index) => (
<tr key={item.id} className="border-b border-gray-300">
<td className="p-2 text-center border-r border-gray-300">{index + 1}</td>
<td className="p-2 text-center border-r border-gray-300">{item.itemCode}</td>
<td className="p-2 border-r border-gray-300">{item.itemName}</td>
<td className="p-2 text-center border-r border-gray-300">{item.spec}</td>
<td className="p-2 text-center border-r border-gray-300">{item.quantity}</td>
<td className="p-2 text-center border-r border-gray-300">{item.unit}</td>
<td className="p-2 text-right border-r border-gray-300">{formatAmount(item.unitPrice)}</td>
<td className="p-2 text-right">{formatAmount(item.amount)}</td>
</tr>
))
) : (
<tr className="border-b border-gray-300">
<td colSpan={8} className="p-4 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 금액 계산 */}
<div className="border border-gray-300 mb-6">
<table className="w-full text-sm">
<tbody>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300 w-32"></td>
<td className="p-2 text-right">{formatAmount(subtotal)}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"></td>
<td className="p-2 text-right">{discountRate}%</td>
</tr>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"></td>
<td className="p-2 text-right text-red-600">-{formatAmount(discountAmount)}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"> </td>
<td className="p-2 text-right">{formatAmount(afterDiscount)}</td>
</tr>
<tr className="border-b border-gray-300">
<td className="p-2 bg-gray-100 border-r border-gray-300"> (10%)</td>
<td className="p-2 text-right">{formatAmount(vat)}</td>
</tr>
<tr>
<td className="p-2 bg-gray-100 border-r border-gray-300 font-medium"> </td>
<td className="p-2 text-right font-bold text-lg"> {formatAmount(finalTotal)}</td>
</tr>
</tbody>
</table>
</div>
{/* 증명 문구 */}
<div className="text-center py-6 border-t border-gray-300">
<p className="text-sm mb-4"> .</p>
<p className="text-sm text-gray-600 mb-4">{orderDate}</p>
<div className="flex justify-center">
<div className="w-12 h-12 border-2 border-red-400 rounded-full flex items-center justify-center text-red-400 text-xs">
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
/**
* 문서 컴포넌트 exports
*/
export { ContractDocument } from "./ContractDocument";
export { TransactionDocument } from "./TransactionDocument";
export { PurchaseOrderDocument } from "./PurchaseOrderDocument";
export {
OrderDocumentModal,
type OrderDocumentType,
type OrderDocumentData,
} from "./OrderDocumentModal";

View File

@@ -0,0 +1,17 @@
/**
* 수주 관련 컴포넌트
*/
export { OrderRegistration, type OrderFormData } from "./OrderRegistration";
export { QuotationSelectDialog, type QuotationForSelect, type QuotationItem } from "./QuotationSelectDialog";
export { ItemAddDialog, type OrderItem } from "./ItemAddDialog";
// 문서 컴포넌트
export {
ContractDocument,
TransactionDocument,
PurchaseOrderDocument,
OrderDocumentModal,
type OrderDocumentType,
type OrderDocumentData,
} from "./documents";