feat(WEB): 자재/영업/품질 모듈 기능 개선 및 문서 컴포넌트 추가

- 입고관리: 상세/목록 UI 개선, actions 로직 강화
- 재고현황: 상세/목록 개선, StockAuditModal 신규 추가
- 영업주문관리: 페이지 구조 개선, OrderSalesDetailEdit 기능 강화
- 주문: OrderRegistration 개선, SalesOrderDocument 신규 추가
- 견적: QuoteTransactionModal 기능 개선
- 품질: InspectionModalV2, ImportInspectionDocument 대폭 개선
- UniversalListPage: 템플릿 기능 확장

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-28 21:15:25 +09:00
parent 79b39a3ef6
commit 1f640622e0
23 changed files with 4683 additions and 1946 deletions

View File

@@ -33,7 +33,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AlertTriangle } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronRight, ChevronsUpDown, Package } from "lucide-react";
import { toast } from "sonner";
import { IntegratedDetailTemplate } from "@/components/templates/IntegratedDetailTemplate";
import { orderSalesConfig } from "./orderSalesConfig";
@@ -50,6 +50,7 @@ import {
interface EditFormData {
// 읽기전용 정보
lotNumber: string;
orderDate: string; // 접수일
quoteNumber: string;
client: string;
siteName: string;
@@ -75,6 +76,16 @@ interface EditFormData {
subtotal: number;
discountRate: number;
totalAmount: number;
// 제품 정보 (아코디언용)
products: Array<{
productName: string;
productCategory?: string;
openWidth?: string;
openHeight?: string;
quantity: number;
floor?: string;
code?: string;
}>;
}
// 배송방식 옵션
@@ -82,6 +93,8 @@ const DELIVERY_METHODS = [
{ value: "direct", label: "직접배차" },
{ value: "pickup", label: "상차" },
{ value: "courier", label: "택배" },
{ value: "self", label: "직접수령" },
{ value: "freight", label: "화물" },
];
// 운임비용 옵션
@@ -126,6 +139,77 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
const [form, setForm] = useState<EditFormData | null>(null);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
// 제품-부품 트리 토글
const toggleProduct = (key: string) => {
setExpandedProducts((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
// 모든 제품 확장
const expandAllProducts = () => {
if (form?.products) {
const allKeys = form.products.map((p) => `${p.floor || ""}-${p.code || ""}`);
allKeys.push("other-parts"); // 기타부품도 포함
setExpandedProducts(new Set(allKeys));
}
};
// 모든 제품 축소
const collapseAllProducts = () => {
setExpandedProducts(new Set());
};
// 제품별로 부품 그룹화 (floor_code, symbol_code 매칭)
const getItemsForProduct = (floor: string | undefined, code: string | undefined) => {
if (!form?.items) return [];
return form.items.filter((item) => {
const itemFloor = item.type || "";
const itemSymbol = item.symbol || "";
const productFloor = floor || "";
const productCode = code || "";
return itemFloor === productFloor && itemSymbol === productCode;
});
};
// 매칭되지 않은 부품 (orphan items)
const getUnmatchedItems = () => {
if (!form?.items || !form?.products) return form?.items || [];
const matchedIds = new Set<string>();
form.products.forEach((product) => {
const items = getItemsForProduct(product.floor, product.code);
items.forEach((item) => matchedIds.add(item.id));
});
return form.items.filter((item) => !matchedIds.has(item.id));
};
/**
* 수량 포맷 함수
* - EA, SET, PCS 등 개수 단위: 정수로 표시
* - M, M2, KG, L 등 측정 단위: 소수점 이하 불필요한 0 제거
*/
const formatQuantity = (quantity: number, unit?: string): string => {
const countableUnits = ["EA", "SET", "PCS", "개", "세트", "BOX", "ROLL"];
const upperUnit = (unit || "").toUpperCase();
if (countableUnits.includes(upperUnit)) {
return Math.round(quantity).toLocaleString();
}
const rounded = Math.round(quantity * 10000) / 10000;
return rounded.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 4
});
};
// 데이터 로드 (API)
useEffect(() => {
@@ -142,6 +226,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
// Order 데이터를 EditFormData로 변환
setForm({
lotNumber: order.lotNumber,
orderDate: order.orderDate || "",
quoteNumber: order.quoteNumber || "",
client: order.client,
siteName: order.siteName,
@@ -163,6 +248,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
subtotal: order.subtotal || order.amount,
discountRate: order.discountRate || 0,
totalAmount: order.amount,
products: order.products || [],
});
} else {
toast.error(result.error || "수주 정보를 불러오는데 실패했습니다.");
@@ -193,10 +279,10 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
return { success: false, error: "납품요청일을 입력해주세요." };
}
if (!form.receiver.trim()) {
return { success: false, error: "수신(반장/업체)을 입력해주세요." };
return { success: false, error: "수신자를 입력해주세요." };
}
if (!form.receiverContact.trim()) {
return { success: false, error: "수신처 연락처를 입력해주세요." };
return { success: false, error: "수신처를 입력해주세요." };
}
try {
@@ -279,30 +365,34 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.lotNumber}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.quoteNumber}</p>
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.orderDate || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.manager}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.client}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.siteName}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.manager || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<p className="font-medium">{form.contact}</p>
<p className="font-medium">{form.contact || "-"}</p>
</div>
<div className="space-y-1">
<Label className="text-muted-foreground text-sm"></Label>
<div className="mt-1">{getOrderStatusBadge(form.status)}</div>
</div>
</div>
</CardContent>
@@ -314,43 +404,17 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
<CardTitle className="text-lg">/ </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 출고예정일 */}
<div className="grid grid-cols-1 md:grid-cols-4 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({ ...form, 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({
...form,
expectedShipDateUndecided: checked as boolean,
expectedShipDate: checked ? "" : form.expectedShipDate,
})
}
/>
<Label
htmlFor="expectedShipDateUndecided"
className="text-sm font-normal"
>
</Label>
</div>
</div>
<Label className="text-muted-foreground"></Label>
<Input
value={form.orderDate || ""}
disabled
className="bg-muted"
/>
</div>
{/* 납품요청일 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
@@ -364,7 +428,18 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
/>
</div>
{/* 배송방식 */}
<div className="space-y-2">
<Label></Label>
<Input
type="date"
value={form.expectedShipDate}
onChange={(e) =>
setForm({ ...form, expectedShipDate: e.target.value })
}
disabled={form.expectedShipDateUndecided}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
@@ -375,7 +450,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
}
>
<SelectTrigger>
<SelectValue placeholder="배송방식 선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{DELIVERY_METHODS.map((method) => (
@@ -387,7 +462,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
</Select>
</div>
{/* 운임비용 */}
{/* 두 번째 줄: 운임비용, 수신자, 수신처 */}
<div className="space-y-2">
<Label></Label>
<Select
@@ -398,7 +473,7 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
}
>
<SelectTrigger>
<SelectValue placeholder="운임비용 선택" />
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{SHIPPING_COSTS.map((cost) => (
@@ -410,50 +485,45 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
</Select>
</div>
{/* 수신(반장/업체) */}
<div className="space-y-2">
<Label>
(/) <span className="text-red-500">*</span>
<span className="text-red-500">*</span>
</Label>
<Input
value={form.receiver}
onChange={(e) =>
setForm({ ...form, receiver: e.target.value })
}
placeholder="수신자명 입력"
/>
</div>
{/* 수신처 연락처 */}
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
<span className="text-red-500">*</span>
</Label>
<PhoneInput
<Input
value={form.receiverContact}
onChange={(value) =>
setForm({ ...form, receiverContact: value })
onChange={(e) =>
setForm({ ...form, receiverContact: e.target.value })
}
placeholder="수신처 입력"
/>
</div>
{/* 수신처 주소 */}
<div className="space-y-2 md:col-span-2">
<Label> </Label>
<Input
value={form.address}
onChange={(e) =>
setForm({ ...form, address: e.target.value })
}
placeholder="주소"
className="mb-2"
/>
<Input
value={form.addressDetail}
onChange={(e) =>
setForm({ ...form, addressDetail: e.target.value })
}
placeholder="상세주소"
/>
{/* 주소 - 전체 너비 */}
<div className="space-y-2 md:col-span-4">
<Label></Label>
<div className="flex gap-2">
<Input
value={form.address}
onChange={(e) =>
setForm({ ...form, address: e.target.value })
}
placeholder="주소"
className="flex-1"
/>
</div>
</div>
</div>
</CardContent>
@@ -474,84 +544,182 @@ export function OrderSalesDetailEdit({ orderId }: OrderSalesDetailEditProps) {
</CardContent>
</Card>
{/* 품목 내역 */}
{/* 제품내용 (아코디언) */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
{!form.canEditItems && (
<span className="flex items-center gap-1 text-sm font-normal text-orange-600">
<AlertTriangle className="h-4 w-4" />
</span>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
{!form.canEditItems && (
<span className="flex items-center gap-1 text-sm font-normal text-orange-600">
<AlertTriangle className="h-4 w-4" />
</span>
)}
</CardTitle>
{form.products && form.products.length > 0 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={expandAllProducts}
className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
>
<ChevronsUpDown className="h-3 w-3" />
</button>
<button
type="button"
onClick={collapseAllProducts}
className="text-xs text-gray-500 hover:text-gray-700"
>
</button>
</div>
)}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center">No</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>(mm)</TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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">{item.quantity}</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>
</TableRow>
))}
</TableBody>
</Table>
</div>
{form.products && form.products.length > 0 ? (
<div className="space-y-3">
{form.products.map((product, productIndex) => {
const productKey = `${product.floor || ""}-${product.code || ""}`;
const isExpanded = expandedProducts.has(productKey);
const productItems = getItemsForProduct(product.floor, product.code);
{/* 합계 */}
<div className="flex flex-col items-end gap-2 pt-4 mt-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>
return (
<div
key={productIndex}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* 제품 헤더 (클릭하면 확장/축소) */}
<button
type="button"
onClick={() => toggleProduct(productKey)}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<Package className="h-5 w-5 text-blue-600" />
<div>
<span className="font-medium">{product.productName}</span>
{product.openWidth && product.openHeight && (
<span className="ml-2 text-sm text-gray-500">
({product.openWidth} × {product.openHeight})
</span>
)}
</div>
</div>
<span className="text-sm text-gray-500">
{productItems.length}
</span>
</button>
{/* 부품 목록 (확장 시 표시) */}
{isExpanded && (
<div className="border-t">
{productItems.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="p-4 text-center text-gray-400 text-sm">
</div>
)}
</div>
)}
</div>
);
})}
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">:</span>
<span className="w-32 text-right">{form.discountRate}%</span>
</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>
) : null}
</CardContent>
</Card>
{/* 기타부품 (아코디언) */}
{(() => {
const unmatchedItems = getUnmatchedItems();
if (unmatchedItems.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
onClick={() => toggleProduct("other-parts")}
className="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<div className="flex items-center gap-3">
{expandedProducts.has("other-parts") ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<Package className="h-5 w-5 text-gray-400" />
<span className="font-medium text-gray-600"></span>
</div>
<span className="text-sm text-gray-500">
{unmatchedItems.length}
</span>
</button>
{expandedProducts.has("other-parts") && (
<div className="border-t">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{unmatchedItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell>{item.spec || "-"}</TableCell>
<TableCell className="text-center">{formatQuantity(item.quantity, item.unit)}</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</CardContent>
</Card>
);
})()}
</div>
);
}, [form]);