공용 DateInput + DataGrid 헤더 가독성 + 구매요청서 수정모드/공급업체 옵션
- 공용 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 헤더.
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function PurchaseRequestRegPage() {
|
||||
const [filter, setFilter] = useState<SalesPurchaseRequestFilter>(EMPTY_FILTER);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editObjid, setEditObjid] = useState<string | undefined>(undefined);
|
||||
const [proposalOpen, setProposalOpen] = useState(false);
|
||||
|
||||
const [purchaseTypeOpts, setPurchaseTypeOpts] = useState<SmartSelectOption[]>([]);
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading} onSearch={handleSearch} onReset={handleReset}
|
||||
actions={<>
|
||||
<Button size="sm" variant="outline" className="h-8 gap-1 px-2 text-xs"
|
||||
onClick={() => setFormOpen(true)}>
|
||||
<FilePlus className="h-3.5 w-3.5" /> 구매요청서작성
|
||||
onClick={handleOpenForm}>
|
||||
<FilePlus className="h-3.5 w-3.5" /> {selectedSrm ? "구매요청서수정" : "구매요청서작성"}
|
||||
</Button>
|
||||
<Button size="sm" variant="default" className="h-8 gap-1 px-2 text-xs"
|
||||
disabled={checkedIds.length !== 1}
|
||||
@@ -215,8 +232,9 @@ export default function PurchaseRequestRegPage() {
|
||||
|
||||
<PurchaseRequestFormDialog
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSaved={() => { fetchList(); setCheckedIds([]); }}
|
||||
srmObjid={editObjid}
|
||||
onClose={() => { setFormOpen(false); setEditObjid(undefined); }}
|
||||
onSaved={() => { fetchList(); setCheckedIds([]); setEditObjid(undefined); }}
|
||||
/>
|
||||
|
||||
{selectedSrm && (
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateInput } from "@/components/common/DateInput";
|
||||
|
||||
interface CompactFilterBarProps {
|
||||
children: React.ReactNode;
|
||||
@@ -100,21 +101,9 @@ export function CompactDateRange({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="date"
|
||||
className="h-7 w-[125px] rounded-md border bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<DateInput size="sm" value={from} onChange={setFrom} disabled={disabled} className="w-[140px]" />
|
||||
<span className="text-xs text-muted-foreground">~</span>
|
||||
<input
|
||||
type="date"
|
||||
className="h-7 w-[125px] rounded-md border bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<DateInput size="sm" value={to} onChange={setTo} disabled={disabled} className="w-[140px]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,11 +170,11 @@ function SortableHeaderCell({
|
||||
style={style}
|
||||
className={cn(
|
||||
widthPx == null && col.width, widthPx == null && col.minWidth,
|
||||
"select-none relative group/th",
|
||||
"select-none relative group/th !px-1.5",
|
||||
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1 w-full">
|
||||
<div className="inline-flex items-center gap-0.5 w-full">
|
||||
<GripVertical
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
@@ -182,13 +182,13 @@ function SortableHeaderCell({
|
||||
aria-label="컬럼 드래그"
|
||||
/>
|
||||
<div
|
||||
className="flex items-center gap-0.5 cursor-pointer min-w-0 flex-1"
|
||||
className="flex items-center gap-0.5 cursor-pointer min-w-0 flex-1 overflow-hidden"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (col.sortable !== false) onSort(col.key);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium whitespace-nowrap" title={col.label}>{col.label}</span>
|
||||
<span className="text-xs font-medium truncate min-w-0" title={col.label}>{col.label}</span>
|
||||
{isSorted && (
|
||||
<span className="text-primary text-xs shrink-0">{sortDir === "asc" ? "↑" : "↓"}</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DateInput — YYYY-MM-DD 형식 통일 공용 날짜 입력 컴포넌트
|
||||
*
|
||||
* 브라우저 `<input type="date">` 는 OS/로케일에 따라 "연도. 월. 일." 등 다른 placeholder 를
|
||||
* 보여주는 문제가 있어, text input + Popover Calendar 로 표시·저장을 YYYY-MM-DD 로 통일.
|
||||
*
|
||||
* - 직접 타이핑: YYYY-MM-DD (8자리 숫자 입력 시 자동으로 - 삽입)
|
||||
* - 캘린더 아이콘 클릭 → Popover Calendar 에서 날짜 선택
|
||||
* - 항상 onChange 에 'YYYY-MM-DD' 또는 빈 문자열 전달
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useEffect, useRef } from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Calendar as CalendarIcon, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { format, parse, isValid } from "date-fns";
|
||||
|
||||
const FMT = "yyyy-MM-dd";
|
||||
|
||||
export interface DateInputProps {
|
||||
value: string; // 'YYYY-MM-DD' 또는 ''
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
/** sm = h-7 (CompactFilter 행), md = h-9 (다이얼로그 폼) */
|
||||
size?: "sm" | "md";
|
||||
/** 값이 있을 때 ✕ 노출 (기본 true). 필수 필드는 false. */
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
function toDate(v: string): Date | null {
|
||||
if (!v) return null;
|
||||
const d = parse(v, FMT, new Date());
|
||||
return isValid(d) ? d : null;
|
||||
}
|
||||
|
||||
function isCompleteDate(v: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
|
||||
const d = parse(v, FMT, new Date());
|
||||
return isValid(d);
|
||||
}
|
||||
|
||||
/** 사용자 타이핑을 8자리 숫자로 받아 YYYY-MM-DD 로 슬라이스 */
|
||||
function autoFormat(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, "").slice(0, 8);
|
||||
if (digits.length <= 4) return digits;
|
||||
if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`;
|
||||
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
export function DateInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = "YYYY-MM-DD",
|
||||
className,
|
||||
size = "md",
|
||||
clearable = true,
|
||||
}: DateInputProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [text, setText] = useState(value || "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 외부 value 동기화
|
||||
useEffect(() => {
|
||||
setText(value || "");
|
||||
}, [value]);
|
||||
|
||||
const selectedDate = useMemo(() => toDate(value), [value]);
|
||||
const invalid = text.length > 0 && !isCompleteDate(text);
|
||||
const showClear = clearable && !disabled && !!value;
|
||||
|
||||
const onTextChange = (raw: string) => {
|
||||
const formatted = autoFormat(raw);
|
||||
setText(formatted);
|
||||
if (formatted === "") {
|
||||
onChange("");
|
||||
} else if (isCompleteDate(formatted)) {
|
||||
onChange(formatted);
|
||||
}
|
||||
// 아직 미완성이면 onChange 호출 안 함 (마지막 유효값 유지)
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
// 미완성 텍스트는 마지막 유효값으로 복귀
|
||||
if (text && !isCompleteDate(text)) setText(value || "");
|
||||
};
|
||||
|
||||
const handleCalendarSelect = (d: Date | undefined) => {
|
||||
if (!d) { onChange(""); setText(""); }
|
||||
else { const v = format(d, FMT); onChange(v); setText(v); }
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onChange("");
|
||||
setText("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const h = size === "sm" ? "h-7" : "h-9";
|
||||
const textCls = size === "sm" ? "text-xs" : "text-sm";
|
||||
|
||||
return (
|
||||
<div className={cn("relative inline-flex items-center", className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder={placeholder}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
className={cn(
|
||||
h, textCls, "pr-16 w-full",
|
||||
invalid && "border-destructive focus-visible:ring-destructive",
|
||||
)}
|
||||
aria-invalid={invalid || undefined}
|
||||
/>
|
||||
{showClear && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="날짜 지우기"
|
||||
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onClick={handleClear}
|
||||
className="absolute right-8 top-1/2 -translate-y-1/2 z-10 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label="캘린더 열기"
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 -translate-y-1/2 z-10 inline-flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
size === "sm" ? "h-5 w-5" : "h-6 w-6",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate || undefined}
|
||||
onSelect={handleCalendarSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<FormState>(EMPTY_FORM);
|
||||
const [parts, setParts] = useState<PartRow[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -67,7 +73,7 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) {
|
||||
// 선택된 프로젝트의 M-BOM 품목 풀 (행추가 시 품번 셀렉트 옵션)
|
||||
const [mbomItems, setMbomItems] = useState<MbomPartItem[]>([]);
|
||||
|
||||
// 모달 열릴 때 옵션 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) {
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>구매요청서 작성</DialogTitle>
|
||||
<DialogTitle>{isEdit ? `구매요청서 수정${form.request_mng_no ? ` — ${form.request_mng_no}` : ""}` : "구매요청서 작성"}</DialogTitle>
|
||||
<DialogDescription>프로젝트 선택 시 주문유형/제품구분/국내외/고객사/유무상이 자동 채워집니다. 품번은 행추가에서 선택하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -233,8 +275,8 @@ export function PurchaseRequestFormDialog({ open, onClose, onSaved }: Props) {
|
||||
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 })} />
|
||||
<DateInput value={form.delivery_request_date}
|
||||
onChange={(v) => setForm({ ...form, delivery_request_date: v })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,11 @@ export const salesPurchaseRequestApi = {
|
||||
listPurchaseRequestReg: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-request", f),
|
||||
listPurchaseRegProposal: (f: SalesPurchaseRequestFilter = {}) => getList("purchase-proposal", f),
|
||||
|
||||
async listVendors(): Promise<Array<{ code: string; label: string }>> {
|
||||
const res = await apiClient.get("/sales/purchase-request/vendors");
|
||||
return (res.data?.data ?? []) as Array<{ code: string; label: string }>;
|
||||
},
|
||||
|
||||
async getProjectAutoFill(projectObjid: string): Promise<ProjectAutoFillInfo | null> {
|
||||
const res = await apiClient.get(`/sales/purchase-request/project-info/${projectObjid}`);
|
||||
return (res.data?.data ?? null) as ProjectAutoFillInfo | null;
|
||||
|
||||
Reference in New Issue
Block a user