feat: [quality] 수주처 선택 UI + client_id 연동 + 수정 저장 개선

- 수주처를 텍스트 입력에서 거래처 검색 선택으로 변경
- 수주 선택 시 거래처+모델 필터 연동 (양방향)
- ProductInspection/Api에 clientId 매핑 추가
- 수정 시 새 개소 locations 필터 (NaN ID 에러 해결)
- SupplierSearchModal 콜백에 id 반환 추가
This commit is contained in:
2026-03-09 17:43:28 +09:00
parent 68331be0ef
commit 7bd4bd38da
5 changed files with 154 additions and 47 deletions

View File

@@ -27,7 +27,7 @@ interface SupplierItem {
interface SupplierSearchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectSupplier: (supplier: { name: string; code?: string }) => void;
onSelectSupplier: (supplier: { id: number | string; name: string; code?: string }) => void;
}
// =============================================================================
@@ -115,7 +115,7 @@ export function SupplierSearchModal({
}, []);
const handleSelect = useCallback((supplier: SupplierItem) => {
onSelectSupplier({ name: supplier.name, code: supplier.clientCode });
onSelectSupplier({ id: supplier.id, name: supplier.name, code: supplier.clientCode });
}, [onSelectSupplier]);
return (

View File

@@ -48,6 +48,9 @@ const OrderSelectModal = dynamic(
const ProductInspectionInputModal = dynamic(
() => import('./ProductInspectionInputModal').then(mod => ({ default: mod.ProductInspectionInputModal })),
);
const SupplierSearchModal = dynamic(
() => import('@/components/material/ReceivingManagement/SupplierSearchModal').then(mod => ({ default: mod.SupplierSearchModal })),
);
import type { InspectionFormData, OrderSettingItem, OrderSelectItem, OrderGroup, ProductInspectionData } from './types';
import {
emptyConstructionSite,
@@ -77,6 +80,7 @@ export function InspectionCreate() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [orderModalOpen, setOrderModalOpen] = useState(false);
const [clientModalOpen, setClientModalOpen] = useState(false);
// 제품검사 입력 모달
const [inspectionInputOpen, setInspectionInputOpen] = useState(false);
@@ -123,10 +127,15 @@ export function InspectionCreate() {
changeReason: '',
}]
);
setFormData((prev) => ({
...prev,
orderItems: [...prev.orderItems, ...newOrderItems],
}));
setFormData((prev) => {
const updated = { ...prev, orderItems: [...prev.orderItems, ...newOrderItems] };
// 수주처 미선택 상태에서 수주 선택 시 → 수주처 자동 채움
if (!prev.clientId && items.length > 0 && items[0].clientId) {
updated.clientId = items[0].clientId ?? undefined;
updated.client = items[0].clientName || '';
}
return updated;
});
}, []);
// ===== 수주 항목 삭제 =====
@@ -238,9 +247,9 @@ export function InspectionCreate() {
toast.error('현장명은 필수 입력 항목입니다.');
return { success: false, error: '현장명을 입력해주세요.' };
}
if (!formData.client.trim()) {
toast.error('수주처는 필수 입력 항목입니다.');
return { success: false, error: '수주처를 입력해주세요.' };
if (!formData.clientId) {
toast.error('수주처는 필수 선택 항목입니다.');
return { success: false, error: '수주처를 선택해주세요.' };
}
setIsSubmitting(true);
@@ -400,11 +409,29 @@ export function InspectionCreate() {
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={formData.client}
onChange={(e) => updateField('client', e.target.value)}
placeholder="수주처 입력"
/>
<div className="flex gap-2">
<Input
value={formData.client}
readOnly
placeholder="거래처를 선택하세요"
className="cursor-pointer bg-muted/30"
onClick={() => setClientModalOpen(true)}
/>
{formData.clientId && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
updateField('client', '');
updateField('clientId', undefined);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
)}
</div>
</div>
<div className="space-y-2">
<Label></Label>
@@ -691,16 +718,28 @@ export function InspectionCreate() {
[formData.orderItems]
);
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
// 수주 선택 필터: 기본정보 수주처 또는 이미 선택된 수주 기준
const orderFilter = useMemo(() => {
if (formData.orderItems.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
const first = formData.orderItems[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}, [formData.orderItems]);
// 기본정보에서 수주처가 선택된 경우 → 해당 거래처 필터
if (formData.clientId) {
const firstItem = formData.orderItems[0];
return {
clientId: formData.clientId,
itemId: firstItem?.itemId ?? undefined,
label: [formData.client, firstItem?.itemName].filter(Boolean).join(' / ') || undefined,
};
}
// 수주가 선택된 경우 → 첫 수주 기준 필터
if (formData.orderItems.length > 0) {
const first = formData.orderItems[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}
return { clientId: undefined, itemId: undefined, label: undefined };
}, [formData.clientId, formData.client, formData.orderItems]);
return (
<>
@@ -725,6 +764,17 @@ export function InspectionCreate() {
filterLabel={orderFilter.label}
/>
{/* 거래처(수주처) 검색 모달 */}
<SupplierSearchModal
open={clientModalOpen}
onOpenChange={setClientModalOpen}
onSelectSupplier={(supplier) => {
updateField('clientId', Number(supplier.id));
updateField('client', supplier.name);
setClientModalOpen(false);
}}
/>
{/* 제품검사 입력 모달 */}
<ProductInspectionInputModal
open={inspectionInputOpen}

View File

@@ -82,6 +82,9 @@ const ProductInspectionInputModal = dynamic(
const OrderSelectModal = dynamic(
() => import('./OrderSelectModal').then(mod => ({ default: mod.OrderSelectModal })),
);
const SupplierSearchModal = dynamic(
() => import('@/components/material/ReceivingManagement/SupplierSearchModal').then(mod => ({ default: mod.SupplierSearchModal })),
);
import type {
ProductInspection,
InspectionFormData,
@@ -137,6 +140,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
// 수주 선택 모달
const [orderModalOpen, setOrderModalOpen] = useState(false);
const [clientModalOpen, setClientModalOpen] = useState(false);
// 문서 모달
const [requestDocOpen, setRequestDocOpen] = useState(false);
@@ -213,6 +217,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
qualityDocNumber: result.data.qualityDocNumber,
siteName: result.data.siteName,
client: result.data.client,
clientId: result.data.clientId,
manager: result.data.manager,
managerContact: result.data.managerContact,
constructionSite: { ...result.data.constructionSite },
@@ -374,10 +379,15 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
changeReason: '',
}]
);
setFormData((prev) => ({
...prev,
orderItems: [...prev.orderItems, ...newOrderItems],
}));
setFormData((prev) => {
const updated = { ...prev, orderItems: [...prev.orderItems, ...newOrderItems] };
// 수주처 미선택 상태에서 수주 선택 시 → 수주처 자동 채움
if (!prev.clientId && items.length > 0 && items[0].clientId) {
updated.clientId = items[0].clientId ?? undefined;
updated.client = items[0].clientName || '';
}
return updated;
});
}, []);
const handleRemoveOrderItem = useCallback((itemId: string) => {
@@ -393,17 +403,29 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
[formData.orderItems]
);
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
// 수주 선택 필터: 기본정보 수주처 또는 이미 선택된 수주 기준
const orderFilter = useMemo(() => {
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
if (items.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
const first = items[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}, [isEditMode, formData.orderItems, inspection?.orderItems]);
// 기본정보에서 수주처가 선택된 경우 → 해당 거래처 필터
if (formData.clientId) {
const firstItem = items[0];
return {
clientId: formData.clientId,
itemId: firstItem?.itemId ?? undefined,
label: [formData.client, firstItem?.itemName].filter(Boolean).join(' / ') || undefined,
};
}
// 수주가 선택된 경우 → 첫 수주 기준 필터
if (items.length > 0) {
const first = items[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}
return { clientId: undefined, itemId: undefined, label: undefined };
}, [isEditMode, formData.clientId, formData.client, formData.orderItems, inspection?.orderItems]);
// ===== 수주 설정 요약 =====
const orderSummary = useMemo(() => {
@@ -976,10 +998,28 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.client}
onChange={(e) => updateField('client', e.target.value)}
/>
<div className="flex gap-2">
<Input
value={formData.client}
readOnly
placeholder="거래처를 선택하세요"
className="cursor-pointer bg-muted/30"
onClick={() => setClientModalOpen(true)}
/>
{formData.clientId && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
updateField('client', '');
updateField('clientId', undefined);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground"></Label>
@@ -1334,6 +1374,17 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
filterLabel={orderFilter.label}
/>
{/* 거래처(수주처) 검색 모달 */}
<SupplierSearchModal
open={clientModalOpen}
onOpenChange={setClientModalOpen}
onSelectSupplier={(supplier) => {
updateField('clientId', Number(supplier.id));
updateField('client', supplier.name);
setClientModalOpen(false);
}}
/>
{/* 제품검사요청서 모달 */}
<InspectionRequestModal
open={requestDocOpen}

View File

@@ -43,6 +43,7 @@ interface ProductInspectionApi {
quality_doc_number: string;
site_name: string;
client: string;
client_id?: number | null;
location_count: number;
required_info: string;
inspection_period: string;
@@ -196,6 +197,7 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
qualityDocNumber: api.quality_doc_number,
siteName: api.site_name,
client: api.client,
clientId: api.client_id ?? undefined,
locationCount: api.location_count,
requiredInfo: api.required_info,
inspectionPeriod: api.inspection_period,
@@ -592,13 +594,16 @@ export async function updateInspection(
});
// 개소별 데이터 (시공규격, 변경사유, 검사데이터)
apiData.locations = data.orderItems.map((item) => ({
id: Number(item.id),
post_width: item.constructionWidth || null,
post_height: item.constructionHeight || null,
change_reason: item.changeReason || null,
inspection_data: item.inspectionData || null,
}));
// 새로 추가된 항목(id가 "orderId-nodeId" 형태)은 order_ids 동기화로 생성되므로 제외
apiData.locations = data.orderItems
.filter((item) => !String(item.id).includes('-') && !isNaN(Number(item.id)))
.map((item) => ({
id: Number(item.id),
post_width: item.constructionWidth || null,
post_height: item.constructionHeight || null,
change_reason: item.changeReason || null,
inspection_data: item.inspectionData || null,
}));
}
const result = await executeServerAction<ProductInspectionApi>({

View File

@@ -152,6 +152,7 @@ export interface ProductInspection {
qualityDocNumber: string; // 품질관리서 번호
siteName: string; // 현장명
client: string; // 수주처
clientId?: number; // 수주처 ID
locationCount: number; // 개소
requiredInfo: string; // 필수정보 (완료 / N건 누락)
inspectionPeriod: string; // 검사기간 (2026-01-01 또는 2026-01-01~2026-01-02)