From 1fb438bdcb4cc069128edba31e14912841084ebc Mon Sep 17 00:00:00 2001 From: hjjeong Date: Fri, 15 May 2026 14:40:28 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20DateInput=20+=20DataGrid?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94=20=EA=B0=80=EB=8F=85=EC=84=B1=20+=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EC=9A=94=EC=B2=AD=EC=84=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EB=AA=A8=EB=93=9C/=EA=B3=B5=EA=B8=89=EC=97=85?= =?UTF-8?q?=EC=B2=B4=20=EC=98=B5=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 DateInput (YYYY-MM-DD 통일): text input + Popover Calendar, 숫자 8자리 자동 - 삽입. CompactDateRange / 다이얼로그 입고요청일 적용. - DataGrid 헤더 라벨 truncate + TableHead 패딩 축소(!px-1.5): 좁은 컬럼에서 라벨 겹침/잘림 해소. - 구매요청서관리 그리드 컬럼 너비 합리화 (총 ~300px 절감)로 품명까지 화면 안에 표시. - 구매요청서 수정모드: 선택 1건 시 [구매요청서수정] 분기 → getDetail 로 헤더/라인 채워 다이얼로그 오픈. 확정·품의서생성 가드. - 공급업체 옵션을 client_mng 기반 listVendorOptions 로 신설 (운영 supply_mng=0 / client_mng=8946, M-BOM vendor 매칭). - 주문유형 CommCodeSelect groupId 0000005 → 0000167 (계약구분). - 고객사 셀렉트 → CustomerSelect 공용 컴포넌트로 교체. - 그리드 delivery_request_date 점 형식 → YYYY-MM-DD 정규화. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/routes/salesPurchaseRequestRoutes.ts | 10 ++ .../services/salesPurchaseRequestService.ts | 21 +++ .../purchase-request/request/page.tsx | 54 ++++-- .../components/common/CompactFilterBar.tsx | 17 +- frontend/components/common/DataGrid.tsx | 8 +- frontend/components/common/DateInput.tsx | 165 ++++++++++++++++++ .../sales/PurchaseRequestFormDialog.tsx | 81 +++++++-- frontend/lib/api/salesPurchaseRequest.ts | 5 + 8 files changed, 312 insertions(+), 49 deletions(-) create mode 100644 frontend/components/common/DateInput.tsx diff --git a/backend-node/src/routes/salesPurchaseRequestRoutes.ts b/backend-node/src/routes/salesPurchaseRequestRoutes.ts index 606029ca..9362d410 100644 --- a/backend-node/src/routes/salesPurchaseRequestRoutes.ts +++ b/backend-node/src/routes/salesPurchaseRequestRoutes.ts @@ -54,6 +54,16 @@ function handleError(res: Response, e: any, label: string) { router.get("/purchase-request", (req, res) => run(svc.listPurchaseRequestReg, req as AuthenticatedRequest, res, "구매요청서관리")); router.get("/purchase-proposal", (req, res) => run(svc.listPurchaseRegProposal, req as AuthenticatedRequest, res, "영업>품의서관리")); +// 공급업체 옵션 (client_mng 기반 — vendor/partner 직접 OBJID) +router.get("/purchase-request/vendors", async (_req, res) => { + try { + const data = await svc.listVendorOptions(); + return res.json({ success: true, data }); + } catch (e: any) { + return handleError(res, e, "공급업체 옵션"); + } +}); + // 프로젝트 자동채움 정보 (주문유형/제품구분/국내외/고객사/유무상 + mbom_header_objid) router.get("/purchase-request/project-info/:projectObjid", async (req, res) => { try { diff --git a/backend-node/src/services/salesPurchaseRequestService.ts b/backend-node/src/services/salesPurchaseRequestService.ts index 34286926..f61c5388 100644 --- a/backend-node/src/services/salesPurchaseRequestService.ts +++ b/backend-node/src/services/salesPurchaseRequestService.ts @@ -336,6 +336,27 @@ export async function getPurchaseRequestDetail(srmObjid: string) { return { header: headRes.rows[0], parts: partRes.rows }; } +// ─── 3-0) 공급업체 옵션 — client_mng 기반 ────────────────────── +// wace partMng 의 fnc_getClientMngListAppend 와 동일. +// M-BOM.vendor / sales_request_part.partner_objid 는 'C_' prefix 없이 client_mng.OBJID 직접 저장. +// → 옵션 코드는 OBJID 그대로(접두 X). 기존 listSupplierOptions(supply_mng)는 다른 메뉴 호환용으로 유지. +export async function listVendorOptions(): Promise<{ code: string; label: string }[]> { + const pool = getPool(); + try { + const r = await pool.query( + `SELECT OBJID::VARCHAR AS code, CLIENT_NM AS label + FROM CLIENT_MNG + WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE') + AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> '' + ORDER BY CLIENT_NM`, + ); + return r.rows; + } catch (e: any) { + logger.error("listVendorOptions 실패", { error: e.message }); + return []; + } +} + // ─── 3-1) 프로젝트 자동채움 정보 (wace purchaseOrderAdminSupplyInfo 1:1) ─ // 프로젝트 선택 시 주문유형(CATEGORY_CD) · 제품구분(PRODUCT) · 국내/해외(AREA_CD) · // 고객사(CUSTOMER_OBJID) · 유/무상(PAID_TYPE) 자동 채움 + M-BOM 헤더. diff --git a/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx b/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx index 60c2b1d9..c53ff04c 100644 --- a/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase-request/request/page.tsx @@ -41,6 +41,7 @@ export default function PurchaseRequestRegPage() { const [filter, setFilter] = useState(EMPTY_FILTER); const [checkedIds, setCheckedIds] = useState([]); const [formOpen, setFormOpen] = useState(false); + const [editObjid, setEditObjid] = useState(undefined); const [proposalOpen, setProposalOpen] = useState(false); const [purchaseTypeOpts, setPurchaseTypeOpts] = useState([]); @@ -90,20 +91,20 @@ export default function PurchaseRequestRegPage() { })), [rows]); const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([ - { key: "request_mng_no", label: "요청번호", width: "w-[150px]", align: "center" }, - { key: "purchase_type_name", label: "구매유형", width: "w-[110px]", align: "center" }, - { key: "project_number", label: "프로젝트번호", width: "w-[150px]", align: "center" }, - { key: "order_type_name", label: "주문유형", width: "w-[110px]", align: "center" }, - { key: "product_name_full", label: "제품구분", width: "w-[110px]", align: "center" }, - { key: "customer_name", label: "고객사", width: "w-[160px]" }, - { key: "paid_type_name", label: "유/무상", width: "w-[90px]", align: "center" }, - { key: "part_display", label: "품번", width: "w-[160px]" }, - { key: "part_name_display", label: "품명", minWidth: "min-w-[200px]" }, - { key: "has_purchase_request_label",label: "구매요청서", width: "w-[110px]", align: "center" }, - { key: "request_user_name", label: "작성자", width: "w-[120px]", align: "center" }, - { key: "delivery_request_date", label: "입고요청일", width: "w-[120px]", align: "center" }, - { key: "regdate_title", label: "작성일", width: "w-[110px]", align: "center" }, - { key: "status_title", label: "상태", width: "w-[110px]", align: "center" }, + { key: "request_mng_no", label: "요청번호", width: "w-[130px]", align: "center" }, + { key: "purchase_type_name", label: "구매유형", width: "w-[90px]", align: "center" }, + { key: "project_number", label: "프로젝트번호", width: "w-[140px]", align: "center" }, + { key: "order_type_name", label: "주문유형", width: "w-[80px]", align: "center" }, + { key: "product_name_full", label: "제품구분", width: "w-[90px]", align: "center" }, + { key: "customer_name", label: "고객사", width: "w-[140px]" }, + { key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" }, + { key: "part_display", label: "품번", width: "w-[140px]" }, + { key: "part_name_display", label: "품명", minWidth: "min-w-[180px]" }, + { key: "has_purchase_request_label",label: "구매요청서", width: "w-[80px]", align: "center" }, + { key: "request_user_name", label: "작성자", width: "w-[100px]", align: "center" }, + { key: "delivery_request_date", label: "입고요청일", width: "w-[100px]", align: "center" }, + { key: "regdate_title", label: "작성일", width: "w-[100px]", align: "center" }, + { key: "status_title", label: "상태", width: "w-[80px]", align: "center" }, ]), []); const summary = useMemo(() => { @@ -131,14 +132,30 @@ export default function PurchaseRequestRegPage() { setProposalOpen(true); }; + // 선택 1건 + 미확정·미상신 → 수정모드 / 그 외(미선택) → 신규 + const handleOpenForm = () => { + if (selectedSrm) { + if (selectedSrm.status_title === "품의서생성") { + return toast.info("이미 품의서가 생성된 항목은 수정할 수 없습니다."); + } + if (selectedSrm.status_title === "확정") { + return toast.info("확정된 구매요청서는 수정할 수 없습니다."); + } + setEditObjid(selectedSrm.objid); + } else { + setEditObjid(undefined); + } + setFormOpen(true); + }; + return (
+ )} + + + + + + + + +
+ ); +} diff --git a/frontend/components/sales/PurchaseRequestFormDialog.tsx b/frontend/components/sales/PurchaseRequestFormDialog.tsx index 10b6bb2c..f3432364 100644 --- a/frontend/components/sales/PurchaseRequestFormDialog.tsx +++ b/frontend/components/sales/PurchaseRequestFormDialog.tsx @@ -15,6 +15,7 @@ import { toast } from "sonner"; import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect"; import { CommCodeSelect } from "@/components/common/CommCodeSelect"; import { CustomerSelect } from "@/components/common/CustomerSelect"; +import { DateInput } from "@/components/common/DateInput"; import { purchaseApi, OptionItem } from "@/lib/api/purchase"; import { salesPurchaseRequestApi, @@ -26,10 +27,14 @@ interface Props { open: boolean; onClose: () => void; onSaved: () => void; + /** 수정 모드 시 기존 SRM OBJID — 미지정이면 신규 등록 */ + srmObjid?: string; } interface FormState { - project_no: string; // PROJECT_MGMT.OBJID + objid?: string; // 수정 모드 시 기존 OBJID + request_mng_no?: string; // 수정 모드 표시용 + project_no: string; // PROJECT_MGMT.OBJID mbom_header_objid: string; purchase_type: string; order_type: string; @@ -55,7 +60,8 @@ const EMPTY_FORM: FormState = { let _rk = 0; const nextKey = () => `r${++_rk}_${Date.now()}`; -export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { +export function PurchaseRequestFormDialog({ open, onClose, onSaved, srmObjid }: Props) { + const isEdit = !!srmObjid; const [form, setForm] = useState(EMPTY_FORM); const [parts, setParts] = useState([]); const [saving, setSaving] = useState(false); @@ -67,7 +73,7 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { // 선택된 프로젝트의 M-BOM 품목 풀 (행추가 시 품번 셀렉트 옵션) const [mbomItems, setMbomItems] = useState([]); - // 모달 열릴 때 옵션 1회 로드 + 폼 초기화 + // 모달 열릴 때 옵션 로드 + (수정모드면) 상세 로드, (신규면) 폼 초기화 useEffect(() => { if (!open) return; setForm(EMPTY_FORM); @@ -75,17 +81,51 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { setMbomItems([]); (async () => { try { - const [proj, suppliers] = await Promise.all([ + const [proj, vendors] = await Promise.all([ purchaseApi.listProjects(), - purchaseApi.listSuppliers(), + salesPurchaseRequestApi.listVendors(), // client_mng 기반 (M-BOM vendor / partner 매칭용) ]); setProjectOpts(proj.map(toSmart)); - setSupplierOpts(suppliers.map(toSmart)); + setSupplierOpts(vendors.map((v) => ({ code: v.code, label: v.label }))); + + if (srmObjid) { + const detail = await salesPurchaseRequestApi.getDetail(srmObjid); + const h = detail.header ?? {}; + const projectObjid = String(h.project_no ?? ""); + // 수정 모드 → M-BOM 풀도 함께 로드 (품번 셀렉트 옵션) + const items = projectObjid + ? await salesPurchaseRequestApi.listMbomParts(projectObjid) + : []; + setMbomItems(items ?? []); + setForm({ + objid: String(h.objid ?? ""), + request_mng_no: h.request_mng_no ?? "", + project_no: projectObjid, + mbom_header_objid: String(h.mbom_header_objid ?? items?.[0]?.mbom_header_objid ?? ""), + purchase_type: h.purchase_type ?? "", + order_type: h.order_type ?? h.category_cd ?? "", + product_name: h.product_name ?? "", + area_cd: h.area_cd ?? "", + customer_objid: h.customer_objid ?? "", + paid_type: h.paid_type ?? "", + delivery_request_date: normalizeDate(h.delivery_request_date), + }); + setParts((detail.parts ?? []).map((p: any) => ({ + rowKey: nextKey(), + objid: p.objid, + part_objid: p.part_objid, + part_no: p.part_no, + part_name: p.part_name, + qty: p.qty, + partner_objid: p.partner_objid ?? "", + partner_price: p.partner_price ?? p.unit_price ?? "", + }))); + } } catch (e: any) { toast.error(`옵션 로드 실패: ${e?.message ?? ""}`); } })(); - }, [open]); + }, [open, srmObjid]); // 프로젝트 선택 → 자동채움 (주문유형/제품구분/국내외/고객사/유무상) + M-BOM 품번 풀 갱신 const onProjectChange = useCallback(async (newProjectObjid: string) => { @@ -176,12 +216,14 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { } setSaving(true); try { + const { request_mng_no, ...rest } = form; // request_mng_no 는 표시 전용, 백엔드 미전송 + void request_mng_no; const payload = { - ...form, - parts: parts.map(({ rowKey, part_no, ...rest }) => rest), // eslint-disable-line @typescript-eslint/no-unused-vars + ...rest, + parts: parts.map(({ rowKey, part_no, ...partRest }) => partRest), // eslint-disable-line @typescript-eslint/no-unused-vars }; const res = await salesPurchaseRequestApi.save(payload); - toast.success(`저장되었습니다. (${res.request_mng_no ?? res.objid})`); + toast.success(`${isEdit ? "수정" : "저장"}되었습니다. (${res.request_mng_no ?? form.request_mng_no ?? res.objid})`); onSaved(); onClose(); } catch (e: any) { @@ -195,7 +237,7 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { { if (!o) onClose(); }}> - 구매요청서 작성 + {isEdit ? `구매요청서 수정${form.request_mng_no ? ` — ${form.request_mng_no}` : ""}` : "구매요청서 작성"} 프로젝트 선택 시 주문유형/제품구분/국내외/고객사/유무상이 자동 채워집니다. 품번은 행추가에서 선택하세요. @@ -233,8 +275,8 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) { onValueChange={(v) => setForm({ ...form, paid_type: v })} /> - setForm({ ...form, delivery_request_date: e.target.value })} /> + setForm({ ...form, delivery_request_date: v })} /> @@ -333,3 +375,16 @@ function Field({ label, children }: { label: string; children: React.ReactNode } function toSmart(o: OptionItem): SmartSelectOption { return { code: o.code, label: o.label }; } + +// 운영 데이터에 'YYYY.MM.DD' 또는 'YYYY/MM/DD' 가 섞여 있을 수 있어 DateInput 입력 형식으로 정규화 +function normalizeDate(v: any): string { + if (!v) return ""; + const s = String(v).trim(); + if (!s) return ""; + const m = s.match(/^(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})/); + if (!m) return ""; + const yyyy = m[1]; + const mm = m[2].padStart(2, "0"); + const dd = m[3].padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} diff --git a/frontend/lib/api/salesPurchaseRequest.ts b/frontend/lib/api/salesPurchaseRequest.ts index 2b77b8d5..805bd701 100644 --- a/frontend/lib/api/salesPurchaseRequest.ts +++ b/frontend/lib/api/salesPurchaseRequest.ts @@ -107,6 +107,11 @@ export const salesPurchaseRequestApi = { listPurchaseRequestReg: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-request", f), listPurchaseRegProposal: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-proposal", f), + async listVendors(): Promise> { + const res = await apiClient.get("/sales/purchase-request/vendors"); + return (res.data?.data ?? []) as Array<{ code: string; label: string }>; + }, + async getProjectAutoFill(projectObjid: string): Promise { const res = await apiClient.get(`/sales/purchase-request/project-info/${projectObjid}`); return (res.data?.data ?? null) as ProjectAutoFillInfo | null;