Files
wace_rps/frontend/components/sales/PurchaseRequestFormDialog.tsx
T
hjjeong 75f4ca8127 영업관리 구매요청 2메뉴 액션 완성 + SmartSelect 키보드 네비
- 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>
2026-05-15 14:01:26 +09:00

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 };
}