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:
312
src/components/orders/ItemAddDialog.tsx
Normal file
312
src/components/orders/ItemAddDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
870
src/components/orders/OrderRegistration.tsx
Normal file
870
src/components/orders/OrderRegistration.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
261
src/components/orders/QuotationSelectDialog.tsx
Normal file
261
src/components/orders/QuotationSelectDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
212
src/components/orders/documents/ContractDocument.tsx
Normal file
212
src/components/orders/documents/ContractDocument.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src/components/orders/documents/OrderDocumentModal.tsx
Normal file
197
src/components/orders/documents/OrderDocumentModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
src/components/orders/documents/PurchaseOrderDocument.tsx
Normal file
202
src/components/orders/documents/PurchaseOrderDocument.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
src/components/orders/documents/TransactionDocument.tsx
Normal file
202
src/components/orders/documents/TransactionDocument.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/components/orders/documents/index.ts
Normal file
12
src/components/orders/documents/index.ts
Normal 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";
|
||||
17
src/components/orders/index.ts
Normal file
17
src/components/orders/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user