fix: 수주등록 모달 및 품목 검색 UI 개선

- 품목 검색 모달에서 컬럼명 대신 라벨명 표시
  * ItemSelectionModal에 columnLabels prop 추가
  * ModalRepeaterTableComponent에서 columns 설정의 라벨 매핑 생성
  * 테이블 헤더에 한글 라벨 표시 (품번, 품명, 규격, 재질 등)

- 이미 추가된 품목은 검색 결과에서 완전 제외
  * filteredResults로 중복 항목 필터링
  * 회색 표시 대신 목록에서 아예 제거
  * 사용자 친화적인 안내 메시지 추가

- 수주등록 버튼 크기 및 렌더링 수정
  * 기본 크기를 200x40에서 120x40으로 변경 (다른 버튼과 통일)
  * h-full w-full 클래스 적용하여 컨테이너 크기에 맞게 렌더링
  * style prop의 width/height 제거하여 Tailwind 클래스 우선순위 문제 해결

- 수주등록 모달에 판매 유형 및 무역 정보 추가
  * 국내/해외 판매 선택 기능
  * 해외 판매 시 무역 정보 섹션 표시 (인코텀즈, 결제조건, 통화 등)
  * 거래처 정보 확장 (담당자, 납품처, 납품장소)

- 품목 반복 테이블 컬럼 조정
  * 품목번호를 품번으로 변경
  * 비고 컬럼 제거
  * 규격, 재질 컬럼 추가
  * 납품일을 납기일로 변경
This commit is contained in:
kjs 2025-11-14 16:19:27 +09:00
parent 64e6fd1920
commit e6949bdd67
7 changed files with 345 additions and 86 deletions

View File

@ -27,7 +27,7 @@ interface OrderItemRepeaterTableProps {
// 수주 등록 전용 컬럼 설정 (고정)
const ORDER_COLUMNS: RepeaterColumnConfig[] = [
{
field: "id",
field: "item_number",
label: "품번",
editable: false,
width: "120px",
@ -36,14 +36,20 @@ const ORDER_COLUMNS: RepeaterColumnConfig[] = [
field: "item_name",
label: "품명",
editable: false,
width: "200px",
width: "180px",
},
{
field: "item_number",
label: "품목번호",
field: "specification",
label: "규격",
editable: false,
width: "150px",
},
{
field: "material",
label: "재질",
editable: false,
width: "120px",
},
{
field: "quantity",
label: "수량",
@ -71,18 +77,11 @@ const ORDER_COLUMNS: RepeaterColumnConfig[] = [
},
{
field: "delivery_date",
label: "납일",
label: "납일",
type: "date",
editable: true,
width: "130px",
},
{
field: "note",
label: "비고",
type: "text",
editable: true,
width: "200px",
},
];
// 수주 등록 전용 계산 공식 (고정)
@ -104,19 +103,20 @@ export function OrderItemRepeaterTable({
// 고정 설정 (수주 등록 전용)
sourceTable="item_info"
sourceColumns={[
"id",
"item_name",
"item_number",
"item_name",
"specification",
"material",
"unit",
"selling_price",
]}
sourceSearchFields={["item_name", "id", "item_number"]}
sourceSearchFields={["item_name", "item_number", "specification"]}
modalTitle="품목 검색 및 선택"
modalButtonText="품목 검색"
multiSelect={true}
columns={ORDER_COLUMNS}
calculationRules={ORDER_CALCULATION_RULES}
uniqueField="id"
uniqueField="item_number"
// 외부에서 제어 가능한 prop
value={value}

View File

@ -39,12 +39,28 @@ export function OrderRegistrationModal({
// 입력 방식
const [inputMode, setInputMode] = useState<string>("customer_first");
// 판매 유형 (국내/해외)
const [salesType, setSalesType] = useState<string>("domestic");
// 단가 기준 (기준단가/거래처별단가)
const [priceType, setPriceType] = useState<string>("standard");
// 폼 데이터
const [formData, setFormData] = useState<any>({
customerCode: "",
customerName: "",
contactPerson: "",
deliveryDestination: "",
deliveryAddress: "",
deliveryDate: "",
memo: "",
// 무역 정보 (해외 판매 시)
incoterms: "",
paymentTerms: "",
currency: "KRW",
portOfLoading: "",
portOfDischarge: "",
hsCode: "",
});
// 선택된 품목 목록
@ -70,13 +86,32 @@ export function OrderRegistrationModal({
setIsSaving(true);
// 수주 등록 API 호출
const response = await apiClient.post("/orders", {
const orderData: any = {
inputMode,
salesType,
priceType,
customerCode: formData.customerCode,
contactPerson: formData.contactPerson,
deliveryDestination: formData.deliveryDestination,
deliveryAddress: formData.deliveryAddress,
deliveryDate: formData.deliveryDate,
items: selectedItems,
memo: formData.memo,
});
};
// 해외 판매 시 무역 정보 추가
if (salesType === "export") {
orderData.tradeInfo = {
incoterms: formData.incoterms,
paymentTerms: formData.paymentTerms,
currency: formData.currency,
portOfLoading: formData.portOfLoading,
portOfDischarge: formData.portOfDischarge,
hsCode: formData.hsCode,
};
}
const response = await apiClient.post("/orders", orderData);
if (response.data.success) {
toast.success("수주가 등록되었습니다");
@ -107,11 +142,22 @@ export function OrderRegistrationModal({
// 폼 초기화
const resetForm = () => {
setInputMode("customer_first");
setSalesType("domestic");
setPriceType("standard");
setFormData({
customerCode: "",
customerName: "",
contactPerson: "",
deliveryDestination: "",
deliveryAddress: "",
deliveryDate: "",
memo: "",
incoterms: "",
paymentTerms: "",
currency: "KRW",
portOfLoading: "",
portOfDischarge: "",
hsCode: "",
});
setSelectedItems([]);
};
@ -133,55 +179,129 @@ export function OrderRegistrationModal({
</DialogHeader>
<div className="space-y-6">
{/* 입력 방식 선택 */}
<div className="space-y-2">
<Label htmlFor="inputMode" className="text-xs sm:text-sm">
*
</Label>
<Select value={inputMode} onValueChange={setInputMode}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="입력 방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="customer_first"> </SelectItem>
<SelectItem value="quotation"> </SelectItem>
<SelectItem value="unit_price"> </SelectItem>
</SelectContent>
</Select>
{/* 상단 셀렉트 박스 3개 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 입력 방식 */}
<div className="space-y-2">
<Label htmlFor="inputMode" className="text-xs sm:text-sm flex items-center gap-1">
<span className="text-amber-500">📝</span>
</Label>
<Select value={inputMode} onValueChange={setInputMode}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="입력 방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="customer_first"> </SelectItem>
<SelectItem value="quotation"> </SelectItem>
<SelectItem value="unit_price"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 판매 유형 */}
<div className="space-y-2">
<Label htmlFor="salesType" className="text-xs sm:text-sm flex items-center gap-1">
<span className="text-blue-500">🌏</span>
</Label>
<Select value={salesType} onValueChange={setSalesType}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="판매 유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="domestic"> </SelectItem>
<SelectItem value="export"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 단가 기준 */}
<div className="space-y-2">
<Label htmlFor="priceType" className="text-xs sm:text-sm flex items-center gap-1">
<span className="text-green-500">💰</span>
</Label>
<Select value={priceType} onValueChange={setPriceType}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="단가 방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard"> </SelectItem>
<SelectItem value="customer"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 입력 방식에 따른 동적 폼 */}
{/* 거래처 정보 (항상 표시) */}
{inputMode === "customer_first" && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* 거래처 검색 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<OrderCustomerSearch
value={formData.customerCode}
onChange={(code, fullData) => {
setFormData({
...formData,
customerCode: code || "",
customerName: fullData?.customer_name || "",
});
}}
/>
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<span>🏢</span>
<span> </span>
</div>
{/* 납품일 */}
<div className="space-y-2">
<Label htmlFor="deliveryDate" className="text-xs sm:text-sm">
</Label>
<Input
id="deliveryDate"
type="date"
value={formData.deliveryDate}
onChange={(e) =>
setFormData({ ...formData, deliveryDate: e.target.value })
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 거래처 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<OrderCustomerSearch
value={formData.customerCode}
onChange={(code, fullData) => {
setFormData({
...formData,
customerCode: code || "",
customerName: fullData?.customer_name || "",
});
}}
/>
</div>
{/* 담당자 */}
<div className="space-y-2">
<Label htmlFor="contactPerson" className="text-xs sm:text-sm">
</Label>
<Input
id="contactPerson"
placeholder="담당자"
value={formData.contactPerson}
onChange={(e) =>
setFormData({ ...formData, contactPerson: e.target.value })
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 납품처 */}
<div className="space-y-2">
<Label htmlFor="deliveryDestination" className="text-xs sm:text-sm">
</Label>
<Input
id="deliveryDestination"
placeholder="납품처"
value={formData.deliveryDestination}
onChange={(e) =>
setFormData({ ...formData, deliveryDestination: e.target.value })
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 납품장소 */}
<div className="space-y-2">
<Label htmlFor="deliveryAddress" className="text-xs sm:text-sm">
</Label>
<Input
id="deliveryAddress"
placeholder="납품장소"
value={formData.deliveryAddress}
onChange={(e) =>
setFormData({ ...formData, deliveryAddress: e.target.value })
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
</div>
)}
@ -228,6 +348,140 @@ export function OrderRegistrationModal({
</div>
)}
{/* 무역 정보 (해외 판매 시에만 표시) */}
{salesType === "export" && (
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
<span>🌏</span>
<span> </span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 인코텀즈 */}
<div className="space-y-2">
<Label htmlFor="incoterms" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.incoterms}
onValueChange={(value) =>
setFormData({ ...formData, incoterms: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="EXW">EXW</SelectItem>
<SelectItem value="FOB">FOB</SelectItem>
<SelectItem value="CIF">CIF</SelectItem>
<SelectItem value="DDP">DDP</SelectItem>
<SelectItem value="DAP">DAP</SelectItem>
</SelectContent>
</Select>
</div>
{/* 결제 조건 */}
<div className="space-y-2">
<Label htmlFor="paymentTerms" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.paymentTerms}
onValueChange={(value) =>
setFormData({ ...formData, paymentTerms: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="advance"></SelectItem>
<SelectItem value="cod"></SelectItem>
<SelectItem value="lc">(L/C)</SelectItem>
<SelectItem value="net30">NET 30</SelectItem>
<SelectItem value="net60">NET 60</SelectItem>
</SelectContent>
</Select>
</div>
{/* 통화 */}
<div className="space-y-2">
<Label htmlFor="currency" className="text-xs sm:text-sm">
</Label>
<Select
value={formData.currency}
onValueChange={(value) =>
setFormData({ ...formData, currency: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="통화 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="KRW">KRW ()</SelectItem>
<SelectItem value="USD">USD ()</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
<SelectItem value="JPY">JPY ()</SelectItem>
<SelectItem value="CNY">CNY ()</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* 선적항 */}
<div className="space-y-2">
<Label htmlFor="portOfLoading" className="text-xs sm:text-sm">
</Label>
<Input
id="portOfLoading"
placeholder="선적항"
value={formData.portOfLoading}
onChange={(e) =>
setFormData({ ...formData, portOfLoading: e.target.value })
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 도착항 */}
<div className="space-y-2">
<Label htmlFor="portOfDischarge" className="text-xs sm:text-sm">
</Label>
<Input
id="portOfDischarge"
placeholder="도착항"
value={formData.portOfDischarge}
onChange={(e) =>
setFormData({ ...formData, portOfDischarge: e.target.value })
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* HS Code */}
<div className="space-y-2">
<Label htmlFor="hsCode" className="text-xs sm:text-sm">
HS Code
</Label>
<Input
id="hsCode"
placeholder="HS Code"
value={formData.hsCode}
onChange={(e) =>
setFormData({ ...formData, hsCode: e.target.value })
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
</div>
)}
{/* 메모 */}
<div className="space-y-2">
<Label htmlFor="memo" className="text-xs sm:text-sm">

View File

@ -28,6 +28,7 @@ export function ItemSelectionModal({
alreadySelected = [],
uniqueField,
onSelect,
columnLabels = {},
}: ItemSelectionModalProps) {
const [localSearchText, setLocalSearchText] = useState("");
const [selectedItems, setSelectedItems] = useState<any[]>([]);
@ -92,6 +93,9 @@ export function ItemSelectionModal({
);
};
// 이미 추가된 항목 제외한 결과 필터링
const filteredResults = results.filter((item) => !isAlreadyAdded(item));
// 선택된 항목인지 확인
const isSelected = (item: any): boolean => {
return selectedItems.some((selected) =>
@ -169,13 +173,13 @@ export function ItemSelectionModal({
key={col}
className="px-4 py-2 text-left font-medium text-muted-foreground"
>
{col}
{columnLabels[col] || col}
</th>
))}
</tr>
</thead>
<tbody>
{loading && results.length === 0 ? (
{loading && filteredResults.length === 0 ? (
<tr>
<td
colSpan={sourceColumns.length + (multiSelect ? 1 : 0)}
@ -185,46 +189,36 @@ export function ItemSelectionModal({
<p className="mt-2 text-muted-foreground"> ...</p>
</td>
</tr>
) : results.length === 0 ? (
) : filteredResults.length === 0 ? (
<tr>
<td
colSpan={sourceColumns.length + (multiSelect ? 1 : 0)}
className="px-4 py-8 text-center text-muted-foreground"
>
{results.length > 0
? "모든 항목이 이미 추가되었습니다"
: "검색 결과가 없습니다"}
</td>
</tr>
) : (
results.map((item, index) => {
const alreadyAdded = isAlreadyAdded(item);
filteredResults.map((item, index) => {
const selected = isSelected(item);
return (
<tr
key={index}
className={`border-t transition-colors ${
alreadyAdded
? "bg-muted/50 opacity-50"
: selected
selected
? "bg-primary/10"
: "hover:bg-accent cursor-pointer"
}`}
onClick={() => {
if (!alreadyAdded) {
handleToggleItem(item);
}
}}
onClick={() => handleToggleItem(item)}
>
{multiSelect && (
<td className="px-4 py-2">
<Checkbox
checked={selected}
disabled={alreadyAdded}
onCheckedChange={() => {
if (!alreadyAdded) {
handleToggleItem(item);
}
}}
onCheckedChange={() => handleToggleItem(item)}
/>
</td>
)}

View File

@ -92,6 +92,12 @@ export function ModalRepeaterTableComponent({
onChange(newData);
};
// 컬럼명 -> 라벨명 매핑 생성
const columnLabels = columns.reduce((acc, col) => {
acc[col.field] = col.label;
return acc;
}, {} as Record<string, string>);
return (
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 */}
@ -130,6 +136,7 @@ export function ModalRepeaterTableComponent({
alreadySelected={value}
uniqueField={uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}
/>
</div>
);

View File

@ -65,5 +65,6 @@ export interface ItemSelectionModalProps {
alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용)
uniqueField?: string;
onSelect: (items: any[]) => void;
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
}

View File

@ -23,14 +23,17 @@ export function OrderRegistrationModalRenderer({
}: OrderRegistrationModalRendererProps) {
const [isOpen, setIsOpen] = useState(false);
// style에서 width, height 제거 (h-full w-full로 제어)
const { width, height, ...restStyle } = style || {};
return (
<>
<Button
variant={buttonVariant}
size={buttonSize}
onClick={() => setIsOpen(true)}
style={style}
className="w-full h-full"
className="h-full w-full"
style={restStyle}
>
<Plus className="mr-2 h-4 w-4" />
{buttonText}

View File

@ -17,7 +17,7 @@ export const OrderRegistrationModalDefinition: Omit<ComponentDefinition, "render
tags: ["수주", "주문", "영업", "모달"],
defaultSize: {
width: 200,
width: 120,
height: 40,
},
@ -29,7 +29,7 @@ export const OrderRegistrationModalDefinition: Omit<ComponentDefinition, "render
defaultProps: {
style: {
width: "200px",
width: "120px",
height: "40px",
},
},