공용 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:
hjjeong
2026-05-15 14:40:28 +09:00
parent 75f4ca8127
commit 1fb438bdcb
8 changed files with 312 additions and 49 deletions
@@ -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>
);
}
+4 -4
View File
@@ -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>
)}
+165
View File
@@ -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}`;
}
+5
View File
@@ -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;