75f4ca8127
- sales_request_part DDL 추출(운영 11133)→RPS(11134) 마이그레이션 - 백엔드 6 엔드포인트: 프로젝트 자동채움/M-BOM 품목/저장/품의서생성/SSO · 품의서 결재상신 Amaranth SSO (target_type=PROPOSAL, formId=1163) - 프론트 다이얼로그 2개 (구매요청서작성 / 품의서생성 확인) · 프로젝트 선택→주문유형·제품구분·국내외·고객사·유무상 자동 채움 · 행추가 시 M-BOM 품번 셀렉트→품명/공급업체/단가 자동 셋팅 - 공용 SmartSelect: ↑↓·Enter·Esc·Home·End·PageUp·Down 키보드 네비 - 그리드 delivery_request_date . → - 형식 정규화 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
"use client";
|
|
|
|
// 영업관리 > 구매요청서관리 — 구매요청서작성 다이얼로그
|
|
// wace 1:1: salesRequestFormPopUp.jsp
|
|
// - 프로젝트 선택 → purchaseOrderAdminSupplyInfo: 주문유형/제품구분/국내외/고객사/유무상 자동 채움
|
|
// - 행추가: 품번 SmartSelect (해당 프로젝트 M-BOM 품목) → 선택 시 품명/공급업체/단가 자동 셋팅
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Plus, Trash2, Save, X } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
|
import { CustomerSelect } from "@/components/common/CustomerSelect";
|
|
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
|
|
import {
|
|
salesPurchaseRequestApi,
|
|
MbomPartItem,
|
|
PurchaseRequestPartInput,
|
|
} from "@/lib/api/salesPurchaseRequest";
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
interface FormState {
|
|
project_no: string; // PROJECT_MGMT.OBJID
|
|
mbom_header_objid: string;
|
|
purchase_type: string;
|
|
order_type: string;
|
|
product_name: string;
|
|
area_cd: string;
|
|
customer_objid: string;
|
|
paid_type: string;
|
|
delivery_request_date: string;
|
|
}
|
|
|
|
interface PartRow extends PurchaseRequestPartInput {
|
|
rowKey: string;
|
|
part_no?: string;
|
|
}
|
|
|
|
const EMPTY_FORM: FormState = {
|
|
project_no: "", mbom_header_objid: "",
|
|
purchase_type: "", order_type: "", product_name: "",
|
|
area_cd: "", customer_objid: "", paid_type: "",
|
|
delivery_request_date: "",
|
|
};
|
|
|
|
let _rk = 0;
|
|
const nextKey = () => `r${++_rk}_${Date.now()}`;
|
|
|
|
export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) {
|
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
|
const [parts, setParts] = useState<PartRow[]>([]);
|
|
const [saving, setSaving] = useState(false);
|
|
const [loadingProject, setLoadingProject] = useState(false);
|
|
|
|
const [projectOpts, setProjectOpts] = useState<SmartSelectOption[]>([]);
|
|
const [supplierOpts, setSupplierOpts] = useState<SmartSelectOption[]>([]);
|
|
|
|
// 선택된 프로젝트의 M-BOM 품목 풀 (행추가 시 품번 셀렉트 옵션)
|
|
const [mbomItems, setMbomItems] = useState<MbomPartItem[]>([]);
|
|
|
|
// 모달 열릴 때 옵션 1회 로드 + 폼 초기화
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setForm(EMPTY_FORM);
|
|
setParts([]);
|
|
setMbomItems([]);
|
|
(async () => {
|
|
try {
|
|
const [proj, suppliers] = await Promise.all([
|
|
purchaseApi.listProjects(),
|
|
purchaseApi.listSuppliers(),
|
|
]);
|
|
setProjectOpts(proj.map(toSmart));
|
|
setSupplierOpts(suppliers.map(toSmart));
|
|
} catch (e: any) {
|
|
toast.error(`옵션 로드 실패: ${e?.message ?? ""}`);
|
|
}
|
|
})();
|
|
}, [open]);
|
|
|
|
// 프로젝트 선택 → 자동채움 (주문유형/제품구분/국내외/고객사/유무상) + M-BOM 품번 풀 갱신
|
|
const onProjectChange = useCallback(async (newProjectObjid: string) => {
|
|
setForm((f) => ({ ...f, project_no: newProjectObjid }));
|
|
setParts([]);
|
|
setMbomItems([]);
|
|
if (!newProjectObjid) {
|
|
setForm((f) => ({
|
|
...f, project_no: "",
|
|
mbom_header_objid: "", order_type: "", product_name: "",
|
|
area_cd: "", customer_objid: "", paid_type: "",
|
|
}));
|
|
return;
|
|
}
|
|
setLoadingProject(true);
|
|
try {
|
|
const [info, items] = await Promise.all([
|
|
salesPurchaseRequestApi.getProjectAutoFill(newProjectObjid),
|
|
salesPurchaseRequestApi.listMbomParts(newProjectObjid),
|
|
]);
|
|
setMbomItems(items ?? []);
|
|
setForm((f) => ({
|
|
...f,
|
|
project_no: newProjectObjid,
|
|
mbom_header_objid: info?.mbom_header_objid ?? (items?.[0]?.mbom_header_objid ?? ""),
|
|
order_type: info?.category_cd ?? "",
|
|
product_name: info?.product ?? "",
|
|
area_cd: info?.area_cd ?? "",
|
|
customer_objid: info?.customer_objid ?? "",
|
|
paid_type: info?.paid_type ?? "",
|
|
}));
|
|
if (!items || items.length === 0) {
|
|
toast.info("선택한 프로젝트에 M-BOM 품목이 없습니다. 품번 선택지가 비어 있습니다.");
|
|
}
|
|
} catch (e: any) {
|
|
toast.error(`프로젝트 정보 조회 실패: ${e?.message ?? ""}`);
|
|
} finally {
|
|
setLoadingProject(false);
|
|
}
|
|
}, []);
|
|
|
|
// M-BOM 품목 → 품번 셀렉트 옵션
|
|
const partOpts: SmartSelectOption[] = useMemo(
|
|
() => mbomItems.map((it) => ({ code: it.part_objid, label: it.part_no || it.part_objid })),
|
|
[mbomItems],
|
|
);
|
|
|
|
const addRow = () => {
|
|
setParts((p) => [
|
|
...p,
|
|
{ rowKey: nextKey(), part_objid: "", part_no: "", part_name: "", qty: "1", partner_objid: "", partner_price: "" },
|
|
]);
|
|
};
|
|
const deleteRow = (rowKey: string) => setParts((p) => p.filter((r) => r.rowKey !== rowKey));
|
|
const updateRow = (rowKey: string, patch: Partial<PartRow>) =>
|
|
setParts((p) => p.map((r) => (r.rowKey === rowKey ? { ...r, ...patch } : r)));
|
|
|
|
// 품번 선택 → M-BOM 메타데이터로 품명/공급업체/단가/수량 자동 셋팅
|
|
const onPartSelect = (rowKey: string, partObjid: string) => {
|
|
const hit = mbomItems.find((it) => it.part_objid === partObjid);
|
|
if (!hit) {
|
|
updateRow(rowKey, { part_objid: partObjid, part_no: "", part_name: "" });
|
|
return;
|
|
}
|
|
updateRow(rowKey, {
|
|
part_objid: hit.part_objid,
|
|
part_no: hit.part_no,
|
|
part_name: hit.part_name,
|
|
qty: hit.qty > 0 ? String(hit.qty) : "1",
|
|
partner_objid: hit.vendor_objid || "",
|
|
partner_price: hit.unit_price > 0 ? String(hit.unit_price) : "",
|
|
});
|
|
};
|
|
|
|
const canSave = useMemo(() => {
|
|
if (!form.project_no) return false;
|
|
if (!form.purchase_type) return false;
|
|
if (parts.length === 0) return false;
|
|
return parts.every((r) => r.part_objid && Number(r.qty || 0) > 0);
|
|
}, [form, parts]);
|
|
|
|
const handleSave = async () => {
|
|
if (!canSave) {
|
|
if (!form.project_no) return toast.error("프로젝트번호를 선택해주세요.");
|
|
if (!form.purchase_type) return toast.error("구매유형을 선택해주세요.");
|
|
if (parts.length === 0) return toast.error("품목이 1건 이상 필요합니다.");
|
|
return toast.error("품번/수량(0 초과)을 모두 입력해주세요.");
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const payload = {
|
|
...form,
|
|
parts: parts.map(({ rowKey, part_no, ...rest }) => rest), // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
};
|
|
const res = await salesPurchaseRequestApi.save(payload);
|
|
toast.success(`저장되었습니다. (${res.request_mng_no ?? res.objid})`);
|
|
onSaved();
|
|
onClose();
|
|
} catch (e: any) {
|
|
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
|
<DialogContent className="max-w-5xl">
|
|
<DialogHeader>
|
|
<DialogTitle>구매요청서 작성</DialogTitle>
|
|
<DialogDescription>프로젝트 선택 시 주문유형/제품구분/국내외/고객사/유무상이 자동 채워집니다. 품번은 행추가에서 선택하세요.</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-4 gap-3 text-sm">
|
|
<Field label="구매유형 *">
|
|
<CommCodeSelect groupId="0001814" value={form.purchase_type}
|
|
onValueChange={(v) => setForm({ ...form, purchase_type: v })} withAll={false} />
|
|
</Field>
|
|
<Field label="프로젝트번호 *">
|
|
<SmartSelect options={projectOpts} value={form.project_no}
|
|
onValueChange={onProjectChange}
|
|
placeholder={loadingProject ? "프로젝트 정보 조회 중..." : "선택"} />
|
|
</Field>
|
|
<Field label="주문유형">
|
|
<CommCodeSelect groupId="0000167" value={form.order_type}
|
|
onValueChange={(v) => setForm({ ...form, order_type: v })} withAll={false} />
|
|
</Field>
|
|
<Field label="제품구분">
|
|
<CommCodeSelect groupId="0000001" value={form.product_name}
|
|
onValueChange={(v) => setForm({ ...form, product_name: v })} withAll={false} />
|
|
</Field>
|
|
|
|
<Field label="국내/해외">
|
|
<CommCodeSelect groupId="0001219" value={form.area_cd}
|
|
onValueChange={(v) => setForm({ ...form, area_cd: v })} withAll={false} />
|
|
</Field>
|
|
<Field label="고객사">
|
|
<CustomerSelect value={form.customer_objid}
|
|
onValueChange={(v) => setForm({ ...form, customer_objid: v })} />
|
|
</Field>
|
|
<Field label="유/무상">
|
|
<SmartSelect
|
|
options={[{ code: "paid", label: "유상" }, { code: "free", label: "무상" }]}
|
|
value={form.paid_type}
|
|
onValueChange={(v) => setForm({ ...form, paid_type: v })} />
|
|
</Field>
|
|
<Field label="입고요청일">
|
|
<Input type="date" value={form.delivery_request_date}
|
|
onChange={(e) => setForm({ ...form, delivery_request_date: e.target.value })} />
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="mt-3 border rounded">
|
|
<div className="flex items-center justify-between border-b px-2 py-1 bg-muted/40 text-xs">
|
|
<div className="font-medium">
|
|
품목 ({parts.length}건)
|
|
{form.project_no ? (
|
|
<span className="ml-2 text-muted-foreground">— 선택 가능 품번 {partOpts.length}건</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs"
|
|
onClick={addRow}
|
|
disabled={!form.project_no}>
|
|
<Plus className="h-3.5 w-3.5" /> 행추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-[360px] overflow-auto">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-muted/30 sticky top-0">
|
|
<tr>
|
|
<th className="px-2 py-1 text-left w-[200px]">품번</th>
|
|
<th className="px-2 py-1 text-left">품명</th>
|
|
<th className="px-2 py-1 text-right w-[90px]">수량</th>
|
|
<th className="px-2 py-1 text-left w-[180px]">공급업체</th>
|
|
<th className="px-2 py-1 text-right w-[110px]">단가</th>
|
|
<th className="px-2 py-1 w-[36px]"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{parts.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-2 py-6 text-center text-muted-foreground">
|
|
{form.project_no
|
|
? "[행추가] 버튼을 눌러 품번을 선택해주세요."
|
|
: "먼저 프로젝트번호를 선택해주세요."}
|
|
</td>
|
|
</tr>
|
|
) : parts.map((r) => (
|
|
<tr key={r.rowKey} className="border-t">
|
|
<td className="px-2 py-1">
|
|
<SmartSelect options={partOpts} value={r.part_objid}
|
|
onValueChange={(v) => onPartSelect(r.rowKey, v)}
|
|
placeholder={partOpts.length === 0 ? "M-BOM 품목 없음" : "선택"} />
|
|
</td>
|
|
<td className="px-2 py-1">{r.part_name || ""}</td>
|
|
<td className="px-2 py-1">
|
|
<Input type="number" min={0} value={r.qty ?? ""} className="h-7 text-right"
|
|
onChange={(e) => updateRow(r.rowKey, { qty: e.target.value })} />
|
|
</td>
|
|
<td className="px-2 py-1">
|
|
<SmartSelect options={supplierOpts} value={r.partner_objid ?? ""}
|
|
onValueChange={(v) => updateRow(r.rowKey, { partner_objid: v })} />
|
|
</td>
|
|
<td className="px-2 py-1">
|
|
<Input type="number" min={0} step="0.01" value={r.partner_price ?? ""} className="h-7 text-right"
|
|
onChange={(e) => updateRow(r.rowKey, { partner_price: e.target.value })} />
|
|
</td>
|
|
<td className="px-2 py-1 text-center">
|
|
<Button size="icon" variant="ghost" className="h-6 w-6"
|
|
onClick={() => deleteRow(r.rowKey)}>
|
|
<Trash2 className="h-3.5 w-3.5 text-red-600" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="mt-2">
|
|
<Button variant="outline" onClick={onClose} disabled={saving}>
|
|
<X className="h-3.5 w-3.5 mr-1" /> 닫기
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={saving || !canSave}>
|
|
<Save className="h-3.5 w-3.5 mr-1" /> {saving ? "저장 중..." : "저장"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<Label className="text-xs">{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function toSmart(o: OptionItem): SmartSelectOption {
|
|
return { code: o.code, label: o.label };
|
|
}
|