b17d7b063d
G11 수주 결재상신(905d5c09)과 동일 패턴을 견적관리에 확장. target_type='CONTRACT_ESTIMATE',
target_objid=estimate_template.objid(최신 차수), formId='1162' (수주 1161과 별도 양식).
- 백엔드: salesEstimateService.startEstimateApproval + POST /sales/estimate/:id/amaranth-approval
- 견적 list SQL: LEFT JOIN amaranth_approval(CONTRACT_ESTIMATE) + APPR_STATUS 4단계 한글 라벨 + approval_required='N' fallback (wace contractMgmt.xml:513~522 1:1)
- 프론트: 견적관리 placeholder 토스트 → handleAmaranthApproval 핸들러 + sky-600 Send 버튼 (수주 페이지와 통일)
- docker-compose 3개: AMARANTH_OUT_PROCESS_CODE_CONTRACT_ESTIMATE + AMARANTH_FORM_ID_CONTRACT_ESTIMATE=1162 추가
- 가드: 행 미선택 / est_objid 없음(견적서 미작성) / inProcess+complete / notRequired+approval_required='N'
- 사전판정(checkApprovalRequired)은 G4 영역으로 분리 — 이번 PR은 단순 SSO 흐름만
검증: BEGIN/ROLLBACK으로 26C-0712(est_objid=-452406811) 4단계 상태(create→inProcess→complete→reject)
+ amaranth row 삭제 시 approval_required='N' fallback 모두 한글 라벨 정상. 문서 08-estimate-approval-verify.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1008 lines
48 KiB
TypeScript
1008 lines
48 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Plus, Save, Trash2, Loader2, Search, Pencil, Send } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
|
import { CustomerSelect } from "@/components/common/CustomerSelect";
|
|
import { PartSelect } from "@/components/common/PartSelect";
|
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
|
import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog";
|
|
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
|
|
import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog";
|
|
import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate";
|
|
|
|
// ─── 컬럼 ─────────────────────────────────────────────────────
|
|
|
|
// wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름
|
|
const GRID_COLUMNS: DataGridColumn[] = [
|
|
{ key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true },
|
|
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
|
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
|
|
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" },
|
|
{ key: "customer_name", label: "고객사", width: "w-[150px]" },
|
|
{ key: "item_summary", label: "품명", width: "w-[200px]" },
|
|
{ key: "estimate_quantity", label: "견적수량", width: "w-[80px]", align: "right", formatNumber: true },
|
|
{ key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" },
|
|
{ key: "est_total_amount", label: "공급가액", width: "w-[110px]", formatMoney: true },
|
|
{ key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[140px]", formatMoney: true },
|
|
{ key: "est_status", label: "견적현황", width: "w-[80px]", align: "center", renderType: "folder" },
|
|
{ key: "add_est_cnt", label: "추가견적", width: "w-[80px]", align: "center", renderType: "clip" },
|
|
{ key: "appr_status", label: "결재상태", width: "w-[90px]", align: "center" },
|
|
{ key: "mail_send_status_label", label: "메일발송", width: "w-[110px]", align: "center" },
|
|
{ key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
|
{ key: "exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
|
|
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
|
{ key: "part_no", label: "품번", width: "w-[120px]" },
|
|
{ key: "writer_name", label: "작성자", width: "w-[100px]" },
|
|
/* wace estimateList_new.jsp 494~502 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
|
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
|
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
|
{ key: "return_reason_summary", label: "반납사유", width: "w-[120px]" },
|
|
*/
|
|
];
|
|
|
|
// ─── 코드 라벨 ────────────────────────────────────────────────
|
|
|
|
const PAID_TYPES: Record<string, string> = { paid: "유상", free: "무상" };
|
|
const PAID_TYPE_OPTIONS = [
|
|
{ value: "", label: "전체" }, { value: "paid", label: "유상" }, { value: "free", label: "무상" },
|
|
];
|
|
|
|
// wace estimateRegistFormPopup 기본값
|
|
const DEFAULT_FORM: EstimateBody = {
|
|
category_cd: "",
|
|
area_cd: "",
|
|
customer_objid: "",
|
|
paid_type: "paid",
|
|
receipt_date: "",
|
|
contract_currency: "",
|
|
exchange_rate: "",
|
|
approval_required: "N",
|
|
items: [],
|
|
};
|
|
|
|
const EMPTY_ITEM: EstimateItem = {
|
|
seq: 1,
|
|
product: "", part_objid: "", part_no: "", part_name: "",
|
|
serials: [], quantity: "", due_date: "", return_reason: "", customer_request: "",
|
|
};
|
|
|
|
// ─── 페이지 ───────────────────────────────────────────────────
|
|
|
|
export default function SalesEstimatePage() {
|
|
const { user } = useAuth();
|
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
|
|
|
// 목록 상태
|
|
const [rows, setRows] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selected, setSelected] = useState<EstimateRow | null>(null);
|
|
|
|
// 검색 폼 — wace 원본 estimateList_new.jsp 활성 7개
|
|
// 주문유형/고객사/품번/품명/S/N/결재상태/접수일
|
|
const [searchForm, setSearchForm] = useState({
|
|
category_cd: "",
|
|
customer_objid: "",
|
|
search_partObjId: "", // 품번 (PartSelect → part_objid)
|
|
search_partName: "", // 품명 (PartSelect → part_objid 별도 사용 시 동기화)
|
|
search_serialNo: "",
|
|
appr_status: "",
|
|
receipt_start_date: "",
|
|
receipt_end_date: "",
|
|
});
|
|
|
|
// 등록/수정 다이얼로그
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [dialogMode, setDialogMode] = useState<"create" | "edit">("create");
|
|
const [saving, setSaving] = useState(false);
|
|
const [form, setForm] = useState<EstimateBody>({ ...DEFAULT_FORM });
|
|
const [contractNoDisplay, setContractNoDisplay] = useState<string>(""); // 자동채번 표시용
|
|
|
|
// 수주확정/수주FCST 행은 품목 추가/삭제 불가 (project_mgmt에 라인 단위로 묶여있음)
|
|
const isContractConfirmed = useMemo(
|
|
() => dialogMode === "edit" && (selected?.contract_result === "0000964" || selected?.contract_result === "0000968"),
|
|
[dialogMode, selected],
|
|
);
|
|
|
|
// 품목 검색 모달 (라인별 진입)
|
|
const [itemDialogOpen, setItemDialogOpen] = useState(false);
|
|
const [itemSearchTargetIdx, setItemSearchTargetIdx] = useState<number | null>(null);
|
|
|
|
// S/N 관리 모달 (wace fn_openItemSnPopup)
|
|
const [serialDialogOpen, setSerialDialogOpen] = useState(false);
|
|
const [serialDialogIdx, setSerialDialogIdx] = useState<number | null>(null);
|
|
const [serialDraft, setSerialDraft] = useState<string[]>([]);
|
|
const [serialInput, setSerialInput] = useState("");
|
|
|
|
// 연속번호 생성 모달 (wace fn_openItemSequentialSnPopup)
|
|
const [seqDialogOpen, setSeqDialogOpen] = useState(false);
|
|
const [seqStartNo, setSeqStartNo] = useState("");
|
|
const [seqCount, setSeqCount] = useState("");
|
|
|
|
// 메일 발송 다이얼로그 — wace estimateMailFormPopup.jsp 1:1
|
|
const [mailDialogOpen, setMailDialogOpen] = useState(false);
|
|
|
|
// 첨부파일 다이얼로그 (추가견적 클립 컬럼 클릭 시)
|
|
// G5 견적작성 — 일반/장비 선택 다이얼로그
|
|
const [templateChoiceOpen, setTemplateChoiceOpen] = useState(false);
|
|
// 견적 차수 리스트 다이얼로그 (est_status folder 클릭)
|
|
const [templateListOpen, setTemplateListOpen] = useState(false);
|
|
const [templateList, setTemplateList] = useState<any[]>([]);
|
|
|
|
function openTemplateChoice() {
|
|
if (!selected) { toast.warning("견적을 선택하세요."); return; }
|
|
setTemplateChoiceOpen(true);
|
|
}
|
|
|
|
function pickTemplate(templateType: "1" | "2") {
|
|
if (!selected) return;
|
|
setTemplateChoiceOpen(false);
|
|
const url = `/COMPANY_16/sales/estimate/template${templateType}/pop/${encodeURIComponent(selected.objid)}`;
|
|
window.open(url, `estimateTemplate_${selected.objid}_${templateType}`,
|
|
"width=1280,height=900,menubar=no,scrollbars=yes,resizable=yes");
|
|
}
|
|
|
|
async function openTemplateList(contractObjid: string) {
|
|
try {
|
|
const list = await salesEstimateApi.listTemplates(contractObjid);
|
|
setTemplateList(list);
|
|
setTemplateListOpen(true);
|
|
} catch (e: any) {
|
|
toast.error("견적 차수 리스트 조회 실패: " + (e?.message ?? ""));
|
|
}
|
|
}
|
|
|
|
function openExistingTemplate(templateObjid: string, templateType: string) {
|
|
const t = templateType === "2" ? "2" : "1";
|
|
const contractObjid = selected?.objid ?? "";
|
|
const url = `/COMPANY_16/sales/estimate/template${t}/pop/${encodeURIComponent(contractObjid)}?templateObjid=${encodeURIComponent(templateObjid)}`;
|
|
window.open(url, `estimateTemplate_${templateObjid}`,
|
|
"width=1280,height=900,menubar=no,scrollbars=yes,resizable=yes");
|
|
setTemplateListOpen(false);
|
|
}
|
|
|
|
const [attachDialogOpen, setAttachDialogOpen] = useState(false);
|
|
const [attachContext, setAttachContext] = useState<{
|
|
targetObjid: string;
|
|
docType: string | string[];
|
|
uploadDocType: string;
|
|
uploadDocTypeName?: string;
|
|
title: string;
|
|
} | null>(null);
|
|
|
|
// 클릭 핸들러를 주입한 그리드 컬럼
|
|
const gridColumns = useMemo<DataGridColumn[]>(
|
|
() => GRID_COLUMNS.map((col) => {
|
|
if (col.key === "add_est_cnt") {
|
|
return {
|
|
...col,
|
|
onClick: (row) => {
|
|
setAttachContext({
|
|
targetObjid: String(row.objid),
|
|
docType: "estimate02",
|
|
uploadDocType: "estimate02",
|
|
uploadDocTypeName: "추가견적",
|
|
title: `추가견적 첨부 — ${row.contract_no ?? ""}`,
|
|
});
|
|
setAttachDialogOpen(true);
|
|
},
|
|
};
|
|
}
|
|
// G5: 견적현황(폴더 아이콘) 클릭 시 차수 리스트 다이얼로그 (wace fn_showEstimateList)
|
|
if (col.key === "est_status") {
|
|
return {
|
|
...col,
|
|
onClick: (row) => {
|
|
if (!row.est_status || Number(row.est_status) === 0) return;
|
|
setSelected(row as EstimateRow);
|
|
openTemplateList(String(row.objid));
|
|
},
|
|
};
|
|
}
|
|
return col;
|
|
}),
|
|
[]
|
|
);
|
|
|
|
// ─── 데이터 로드 ────────────────────────────────────────────
|
|
|
|
const fetchList = useCallback(async () => {
|
|
if (!user) return;
|
|
setLoading(true);
|
|
try {
|
|
const params: Record<string, string> = {};
|
|
Object.entries(searchForm).forEach(([k, v]) => { if (v) params[k] = v; });
|
|
const data = await salesEstimateApi.list(params);
|
|
const mapped = data.map((r) => ({
|
|
...r,
|
|
id: r.objid,
|
|
category_name: r.category_name ?? r.category_cd ?? "",
|
|
paid_type_name: r.paid_type_name ?? PAID_TYPES[r.paid_type ?? ""] ?? "",
|
|
earliest_due_date_label: r.other_due_date_count && r.other_due_date_count > 0
|
|
? `${r.earliest_due_date} 외 ${r.other_due_date_count}건`
|
|
: (r.earliest_due_date ?? ""),
|
|
mail_send_status_label: r.mail_send_status === "Y"
|
|
? `발송 ${r.mail_send_date ?? ""}`
|
|
: (r.mail_send_status === "N" ? "실패" : ""),
|
|
}));
|
|
setRows(mapped);
|
|
} catch (err: any) {
|
|
toast.error("견적 목록 조회 실패");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [user, searchForm]);
|
|
|
|
useEffect(() => { fetchList(); }, [fetchList]);
|
|
|
|
// ─── 다이얼로그 열기 ────────────────────────────────────────
|
|
|
|
const openCreate = async () => {
|
|
setDialogMode("create");
|
|
const contractNo = await salesEstimateApi.generateNumber().catch(() => "");
|
|
setContractNoDisplay(contractNo);
|
|
setForm({
|
|
...DEFAULT_FORM,
|
|
receipt_date: new Date().toISOString().slice(0, 10),
|
|
contract_no: contractNo,
|
|
items: [{ ...EMPTY_ITEM }],
|
|
});
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const openEdit = async () => {
|
|
if (!selected) { toast.warning("수정할 견적을 선택하세요."); return; }
|
|
setDialogMode("edit");
|
|
try {
|
|
const detail = await salesEstimateApi.detail(selected.objid);
|
|
if (!detail) { toast.error("견적 상세 조회 실패"); return; }
|
|
setContractNoDisplay(detail.contract_no ?? "");
|
|
setForm({
|
|
contract_no: detail.contract_no ?? "",
|
|
category_cd: detail.category_cd ?? "",
|
|
area_cd: detail.area_cd ?? "",
|
|
customer_objid: detail.customer_objid ?? "",
|
|
paid_type: detail.paid_type ?? "paid",
|
|
receipt_date: detail.receipt_date ?? "",
|
|
contract_currency: detail.contract_currency ?? "",
|
|
exchange_rate: detail.exchange_rate ?? "",
|
|
approval_required: detail.approval_required ?? "N",
|
|
items: (detail.items ?? []).map((it: any) => ({
|
|
objid: it.objid,
|
|
seq: it.seq,
|
|
product: it.product ?? "",
|
|
part_objid: it.part_objid ?? "",
|
|
part_no: it.master_part_no ?? it.part_no ?? "",
|
|
part_name: it.master_part_name ?? it.part_name ?? "",
|
|
serials: it.serials ?? [],
|
|
quantity: it.quantity ?? "",
|
|
due_date: it.due_date ?? "",
|
|
return_reason: it.return_reason ?? "",
|
|
customer_request: it.customer_request ?? "",
|
|
})),
|
|
});
|
|
setDialogOpen(true);
|
|
} catch {
|
|
toast.error("견적 상세 조회 실패");
|
|
}
|
|
};
|
|
|
|
// ─── 저장 ───────────────────────────────────────────────────
|
|
// wace estimateRegistFormPopup 검증: 주문유형/국내해외/고객사/유무상/접수일/결재여부 필수,
|
|
// 라인은 제품구분/품번/품명 필수.
|
|
|
|
const handleSave = async () => {
|
|
if (!form.category_cd) { toast.error("주문유형을 선택하세요."); return; }
|
|
if (!form.area_cd) { toast.error("국내/해외를 선택하세요."); return; }
|
|
if (!form.customer_objid) { toast.error("고객사를 선택하세요."); return; }
|
|
if (!form.paid_type) { toast.error("유/무상을 선택하세요."); return; }
|
|
if (!form.receipt_date) { toast.error("접수일을 입력하세요."); return; }
|
|
if (!form.approval_required) { toast.error("결재여부를 선택하세요."); return; }
|
|
const items = form.items ?? [];
|
|
if (items.length === 0) { toast.error("품목을 1건 이상 입력하세요."); return; }
|
|
for (const it of items) {
|
|
if (!it.product) { toast.error(`라인 ${it.seq}: 제품구분 필수`); return; }
|
|
if (!it.part_objid) { toast.error(`라인 ${it.seq}: 품번 필수`); return; }
|
|
if (!it.part_name) { toast.error(`라인 ${it.seq}: 품명 필수`); return; }
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
if (dialogMode === "create") {
|
|
await salesEstimateApi.create(form);
|
|
toast.success("견적요청이 등록되었습니다.");
|
|
} else if (selected) {
|
|
await salesEstimateApi.update(selected.objid, form);
|
|
toast.success("견적요청이 수정되었습니다.");
|
|
}
|
|
setDialogOpen(false);
|
|
setSelected(null);
|
|
await fetchList();
|
|
} catch (err: any) {
|
|
toast.error(`저장 실패: ${err?.response?.data?.message ?? err.message}`);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// ─── 삭제 ───────────────────────────────────────────────────
|
|
|
|
const handleDelete = async () => {
|
|
if (!selected) { toast.warning("삭제할 견적을 선택하세요."); return; }
|
|
const ok = await confirm("견적 삭제", { description: `${selected.contract_no ?? selected.objid} 을(를) 삭제하시겠습니까?`, variant: "destructive" });
|
|
if (!ok) return;
|
|
try {
|
|
await salesEstimateApi.remove(selected.objid);
|
|
toast.success("삭제되었습니다.");
|
|
setSelected(null);
|
|
await fetchList();
|
|
} catch (err: any) {
|
|
toast.error(`삭제 실패: ${err?.response?.data?.message ?? err.message}`);
|
|
}
|
|
};
|
|
|
|
// ─── 결재상신 (G4/G11 동일 패턴 — 견적, target_type='CONTRACT_ESTIMATE') ─────
|
|
// wace estimateList_new.jsp:155 btnApproval + :887 fn_openAmaranthApproval 1:1.
|
|
// 단순 SSO 흐름만 — 사전판정(checkApprovalRequired) 분기는 G4 영역으로 분리.
|
|
// 가드: 행 미선택 / est_objid 없음(견적서 미작성) / inProcess·complete / 결재불필요
|
|
const handleAmaranthApproval = async () => {
|
|
if (!selected) { toast.warning("결재상신할 행을 선택해주십시오."); return; }
|
|
if (!selected.est_objid) { toast.warning("견적서를 먼저 작성해주세요."); return; }
|
|
const amaranthStatus = String(selected.amaranth_status ?? "");
|
|
if (amaranthStatus === "inProcess") { toast.warning("결재 진행중인 건은 상신할 수 없습니다."); return; }
|
|
if (amaranthStatus === "complete") { toast.warning("결재 완료된 건은 상신할 수 없습니다."); return; }
|
|
if (amaranthStatus === "notRequired" || selected.approval_required === "N") {
|
|
toast.warning("결재불필요로 처리된 건입니다."); return;
|
|
}
|
|
const ok = await confirm("결재상신", { description: "결재상신 하시겠습니까?" });
|
|
if (!ok) return;
|
|
try {
|
|
const { fullUrl } = await salesEstimateApi.startApproval(selected.objid, {
|
|
approvalTitle: `견적서 결재${selected.contract_no ? " - " + selected.contract_no : ""}`,
|
|
});
|
|
if (!fullUrl) { toast.error("결재 SSO URL을 받지 못했습니다."); return; }
|
|
window.open(fullUrl, "amaranthApproval", "width=1200,height=900,scrollbars=yes,resizable=yes");
|
|
await fetchList();
|
|
} catch (err: any) {
|
|
toast.error(err?.response?.data?.message ?? "결재 시스템 연동 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
// ─── 메일 발송 ──────────────────────────────────────────────
|
|
// 실제 SMTP는 backend-node SMTP_SEND_SWITCH='Y'일 때 sales 계정(sales@rps-korea.com)으로 발송.
|
|
// PDF 첨부: 견적관리 → "메일발송" 클릭 → EstimateMailDialog가
|
|
// 최신 차수 template1/template2 페이지를 hidden iframe에 렌더 →
|
|
// fn_generateAndUploadPdf로 base64 추출 → backend가 estimate02 N건과 합본 첨부.
|
|
|
|
const openMailDialog = () => {
|
|
if (!selected) {
|
|
toast.warning("메일 발송할 견적을 선택하세요.");
|
|
return;
|
|
}
|
|
setMailDialogOpen(true);
|
|
};
|
|
|
|
// ─── 라인 편집 ──────────────────────────────────────────────
|
|
|
|
const updateItem = (idx: number, key: keyof EstimateItem, val: any) => {
|
|
setForm((prev) => {
|
|
const items = [...(prev.items ?? [])];
|
|
items[idx] = { ...items[idx], [key]: val };
|
|
return { ...prev, items };
|
|
});
|
|
};
|
|
const addItem = () => {
|
|
setForm((prev) => ({
|
|
...prev,
|
|
items: [...(prev.items ?? []), { ...EMPTY_ITEM, seq: (prev.items?.length ?? 0) + 1 }],
|
|
}));
|
|
};
|
|
const removeItem = (idx: number) => {
|
|
setForm((prev) => {
|
|
const items = (prev.items ?? []).filter((_, i) => i !== idx).map((it, i) => ({ ...it, seq: i + 1 }));
|
|
return { ...prev, items };
|
|
});
|
|
};
|
|
|
|
// S/N 관리 (wace fn_openItemSnPopup): 라인별 시리얼 추가/삭제 + 연속번호생성
|
|
const openSerialDialog = (idx: number) => {
|
|
const item = form.items?.[idx];
|
|
setSerialDialogIdx(idx);
|
|
setSerialDraft([...(item?.serials ?? [])]);
|
|
setSerialInput("");
|
|
setSerialDialogOpen(true);
|
|
};
|
|
const addSerialDraft = () => {
|
|
const v = serialInput.trim();
|
|
if (!v) { toast.warning("S/N을 입력해주세요."); return; }
|
|
if (serialDraft.includes(v)) { toast.warning("이미 등록된 S/N입니다."); return; }
|
|
setSerialDraft((prev) => [...prev, v]);
|
|
setSerialInput("");
|
|
};
|
|
const removeSerialDraft = (i: number) => {
|
|
setSerialDraft((prev) => prev.filter((_, k) => k !== i));
|
|
};
|
|
const applySerialDraft = () => {
|
|
if (serialDialogIdx === null) return;
|
|
updateItem(serialDialogIdx, "serials", [...serialDraft]);
|
|
setSerialDialogOpen(false);
|
|
};
|
|
|
|
// 연속번호 생성 (wace fn_openItemSequentialSnPopup + fn_generateItemSequentialSn)
|
|
const openSeqDialog = () => {
|
|
setSeqStartNo("");
|
|
setSeqCount("");
|
|
setSeqDialogOpen(true);
|
|
};
|
|
const generateSequentialSn = () => {
|
|
const startNo = seqStartNo.trim();
|
|
const count = parseInt(seqCount, 10);
|
|
if (!startNo) { toast.warning("시작 번호를 입력해주세요."); return; }
|
|
if (!count || count < 1) { toast.warning("생성 개수를 1 이상 입력해주세요."); return; }
|
|
if (count > 100) { toast.warning("최대 100개까지만 생성 가능합니다."); return; }
|
|
// wace 정규식: prefix + 숫자
|
|
const m = startNo.match(/^(.*?)(\d+)$/);
|
|
if (!m) { toast.warning("올바른 형식이 아닙니다. 마지막에 숫자가 있어야 합니다. (예: ITEM-001)"); return; }
|
|
const prefix = m[1];
|
|
const startNum = parseInt(m[2], 10);
|
|
const numLength = m[2].length;
|
|
setSerialDraft((prev) => {
|
|
const next = [...prev];
|
|
for (let i = 0; i < count; i++) {
|
|
const padded = String(startNum + i).padStart(numLength, "0");
|
|
const sn = prefix + padded;
|
|
if (!next.includes(sn)) next.push(sn);
|
|
}
|
|
return next;
|
|
});
|
|
setSeqDialogOpen(false);
|
|
};
|
|
|
|
// ─── 렌더 ───────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
|
|
{ConfirmDialogComponent}
|
|
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold">영업관리 _ 견적관리</h1>
|
|
<p className="text-sm text-muted-foreground">총 {rows.length}건</p>
|
|
</div>
|
|
<div className="flex gap-2 flex-wrap">
|
|
<Button variant="outline" size="sm" onClick={fetchList} disabled={loading}>
|
|
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Search className="w-4 h-4 mr-1" />}
|
|
조회
|
|
</Button>
|
|
<Button size="sm" variant="destructive" onClick={handleDelete} disabled={!selected}>
|
|
<Trash2 className="w-4 h-4 mr-1" />삭제
|
|
</Button>
|
|
<Button size="sm" onClick={() => { if (selected) openEdit(); else openCreate(); }}>
|
|
{selected ? <Pencil className="w-4 h-4 mr-1" /> : <Plus className="w-4 h-4 mr-1" />}
|
|
{selected ? "견적요청수정" : "견적요청등록"}
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={openTemplateChoice} disabled={!selected}>
|
|
<Pencil className="w-4 h-4 mr-1" />견적작성
|
|
</Button>
|
|
<Button size="sm" className="bg-sky-600 hover:bg-sky-700 text-white"
|
|
onClick={handleAmaranthApproval} disabled={!selected}>
|
|
<Send className="w-4 h-4 mr-1" />결재상신
|
|
</Button>
|
|
<Button size="sm" variant="outline" disabled={!selected}
|
|
onClick={openMailDialog}>
|
|
메일발송
|
|
</Button>
|
|
<Button size="sm" variant="ghost"
|
|
onClick={() => setSearchForm({
|
|
category_cd: "", customer_objid: "",
|
|
search_partObjId: "", search_partName: "", search_serialNo: "",
|
|
appr_status: "",
|
|
receipt_start_date: "", receipt_end_date: "",
|
|
})}>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 폼 — wace 원본 estimateList_new.jsp 활성 7개 */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-x-2 gap-y-1.5 p-2 border rounded-md bg-muted/30">
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">주문유형</Label>
|
|
<CommCodeSelect groupId="0000167"
|
|
value={searchForm.category_cd}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v })}
|
|
className="h-8 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">고객사</Label>
|
|
<CustomerSelect
|
|
value={searchForm.customer_objid}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })}
|
|
className="h-8 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">품번</Label>
|
|
<PartSelect mode="partNo"
|
|
value={searchForm.search_partObjId}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
|
|
className="h-8 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">품명</Label>
|
|
<PartSelect mode="partName"
|
|
value={searchForm.search_partObjId}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })}
|
|
className="h-8 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">S/N</Label>
|
|
<Input className="h-8 text-xs" value={searchForm.search_serialNo}
|
|
onChange={(e) => setSearchForm({ ...searchForm, search_serialNo: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">결재상태</Label>
|
|
<Select value={searchForm.appr_status || "all"}
|
|
onValueChange={(v) => setSearchForm({ ...searchForm, appr_status: v === "all" ? "" : v })}>
|
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체</SelectItem>
|
|
<SelectItem value="작성중">작성중</SelectItem>
|
|
<SelectItem value="결재중">결재중</SelectItem>
|
|
<SelectItem value="결재완료">결재완료</SelectItem>
|
|
<SelectItem value="반려">반려</SelectItem>
|
|
<SelectItem value="결재불필요">결재불필요</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px] mb-0.5 block text-muted-foreground">접수일</Label>
|
|
<div className="flex gap-0.5 items-center">
|
|
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.receipt_start_date}
|
|
onChange={(e) => setSearchForm({ ...searchForm, receipt_start_date: e.target.value })} />
|
|
<span className="text-[11px] text-muted-foreground">~</span>
|
|
<Input type="date" className="h-8 text-xs px-1 flex-1 min-w-0" value={searchForm.receipt_end_date}
|
|
onChange={(e) => setSearchForm({ ...searchForm, receipt_end_date: e.target.value })} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */}
|
|
<DataGrid
|
|
columns={gridColumns}
|
|
data={rows}
|
|
showCheckbox
|
|
checkedIds={selected ? [selected.objid] : []}
|
|
onCheckedChange={(ids) => {
|
|
if (ids.length === 0) { setSelected(null); return; }
|
|
const last = ids[ids.length - 1];
|
|
setSelected(rows.find((r) => r.id === last) ?? null);
|
|
}}
|
|
selectedId={selected ? selected.objid : null}
|
|
onSelect={(id) => {
|
|
setSelected(id ? rows.find((r) => r.id === id) ?? null : null);
|
|
}}
|
|
onRowDoubleClick={(row) => { setSelected(row); openEdit(); }}
|
|
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
|
loading={loading}
|
|
/>
|
|
|
|
{/* 등록/수정 Dialog */}
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent
|
|
className="!max-w-[95vw] w-[95vw] max-h-[92vh] overflow-y-auto"
|
|
onInteractOutside={(e) => e.preventDefault()} /* 자식 S/N·연속번호 Dialog 닫힐 때 부모까지 닫히는 현상 차단 */
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>{dialogMode === "create" ? "견적 등록" : "견적 수정"}</DialogTitle>
|
|
<DialogDescription>
|
|
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* 견적요청 기본정보 — wace estimateRegistFormPopup.jsp 1행/2행 (8개) */}
|
|
<fieldset className="border rounded-md p-3">
|
|
<legend className="text-sm font-semibold px-2">견적요청 기본정보</legend>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<div>
|
|
<Label className="text-xs">영업번호</Label>
|
|
<Input readOnly className="bg-muted/30" value={contractNoDisplay} placeholder="저장 시 자동 부여" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">주문유형 <span className="text-rose-600">*</span></Label>
|
|
<CommCodeSelect groupId="0000167"
|
|
value={form.category_cd}
|
|
onValueChange={(v) => setForm({ ...form, category_cd: v })} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">국내/해외 <span className="text-rose-600">*</span></Label>
|
|
<CommCodeSelect groupId="0001219"
|
|
value={form.area_cd}
|
|
onValueChange={(v) => setForm({ ...form, area_cd: v })} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">고객사 <span className="text-rose-600">*</span></Label>
|
|
<CustomerSelect
|
|
value={form.customer_objid}
|
|
onValueChange={(v) => setForm({ ...form, customer_objid: v })} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">유/무상 <span className="text-rose-600">*</span></Label>
|
|
<Select value={form.paid_type || undefined}
|
|
onValueChange={(v) => setForm({ ...form, paid_type: v })}>
|
|
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="paid">유상</SelectItem>
|
|
<SelectItem value="free">무상</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">접수일 <span className="text-rose-600">*</span></Label>
|
|
<Input type="date" value={form.receipt_date}
|
|
onChange={(e) => setForm({ ...form, receipt_date: e.target.value })} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">견적환종</Label>
|
|
<CommCodeSelect groupId="0001533"
|
|
value={form.contract_currency ?? ""}
|
|
onValueChange={(v) => setForm({ ...form, contract_currency: v })} />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">견적환율</Label>
|
|
<Input value={form.exchange_rate ?? ""}
|
|
onChange={(e) => setForm({ ...form, exchange_rate: e.target.value })} />
|
|
</div>
|
|
<div className="col-span-4">
|
|
<Label className="text-xs">결재여부 <span className="text-rose-600">*</span></Label>
|
|
<div className="flex items-center gap-4 pt-1">
|
|
<label className="flex items-center gap-1.5 cursor-pointer text-sm">
|
|
<input type="checkbox" checked={form.approval_required === "Y"}
|
|
onChange={() => setForm({ ...form, approval_required: "Y" })} /> 필요
|
|
</label>
|
|
<label className="flex items-center gap-1.5 cursor-pointer text-sm">
|
|
<input type="checkbox" checked={form.approval_required === "N"}
|
|
onChange={() => setForm({ ...form, approval_required: "N" })} /> 불필요
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
{/* 품목정보 — wace 8컬럼 (제품구분/품번/품명/S/N/견적수량/요청납기/반납사유/고객요청사항) */}
|
|
<fieldset className="border rounded-md p-3 space-y-2">
|
|
<legend className="text-sm font-semibold px-2 flex items-center justify-between w-full">
|
|
<span>품목정보</span>
|
|
</legend>
|
|
{isContractConfirmed && (
|
|
<p className="text-[11px] text-rose-600">
|
|
※ 수주확정된 건은 품목 추가/삭제가 불가합니다. (프로젝트 자동생성과 정합성 보호)
|
|
</p>
|
|
)}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="p-2 w-10 whitespace-nowrap">No</th>
|
|
<th className="p-2 w-28 whitespace-nowrap">제품구분 <span className="text-rose-600">*</span></th>
|
|
<th className="p-2 w-40 whitespace-nowrap">품번 <span className="text-rose-600">*</span></th>
|
|
<th className="p-2 w-56 whitespace-nowrap">품명 <span className="text-rose-600">*</span></th>
|
|
<th className="p-2 w-40 whitespace-nowrap">S/N</th>
|
|
<th className="p-2 w-20 whitespace-nowrap">견적수량</th>
|
|
<th className="p-2 w-36 whitespace-nowrap">요청납기</th>
|
|
<th className="p-2 w-28 whitespace-nowrap">반납사유</th>
|
|
<th className="p-2 min-w-[260px] whitespace-nowrap">고객요청사항</th>
|
|
<th className="p-2 w-12 whitespace-nowrap">삭제</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(form.items ?? []).map((it, idx) => (
|
|
<tr key={idx} className="border-t">
|
|
<td className="p-1 text-center">{it.seq}</td>
|
|
<td className="p-1">
|
|
<CommCodeSelect groupId="0000001"
|
|
value={it.product}
|
|
onValueChange={(v) => updateItem(idx, "product", v)} />
|
|
</td>
|
|
<td className="p-1">
|
|
<PartSelect
|
|
mode="partNo"
|
|
value={it.part_objid}
|
|
onValueChange={(partObjId, row) => {
|
|
setForm((prev) => {
|
|
const items = [...(prev.items ?? [])];
|
|
items[idx] = {
|
|
...items[idx],
|
|
part_objid: partObjId,
|
|
part_no: row?.item_number ?? "",
|
|
part_name: row?.item_name ?? items[idx].part_name,
|
|
};
|
|
return { ...prev, items };
|
|
});
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="p-1">
|
|
<PartSelect
|
|
mode="partName"
|
|
value={it.part_objid}
|
|
onValueChange={(partObjId, row) => {
|
|
setForm((prev) => {
|
|
const items = [...(prev.items ?? [])];
|
|
items[idx] = {
|
|
...items[idx],
|
|
part_objid: partObjId,
|
|
part_no: row?.item_number ?? items[idx].part_no,
|
|
part_name: row?.item_name ?? "",
|
|
};
|
|
return { ...prev, items };
|
|
});
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="p-1">
|
|
<Input className="h-8" readOnly
|
|
value={(it.serials ?? []).join(", ")}
|
|
onClick={() => openSerialDialog(idx)}
|
|
placeholder="클릭하여 S/N 추가" />
|
|
</td>
|
|
<td className="p-1">
|
|
<Input className="h-8 text-right" type="number" min={0}
|
|
value={it.quantity ?? ""}
|
|
onChange={(e) => updateItem(idx, "quantity", e.target.value)} />
|
|
</td>
|
|
<td className="p-1">
|
|
<Input className="h-8" type="date"
|
|
value={it.due_date ?? ""}
|
|
onChange={(e) => updateItem(idx, "due_date", e.target.value)} />
|
|
</td>
|
|
<td className="p-1">
|
|
<CommCodeSelect groupId="0001810"
|
|
value={it.return_reason ?? ""}
|
|
onValueChange={(v) => updateItem(idx, "return_reason", v)} />
|
|
</td>
|
|
<td className="p-1">
|
|
<Textarea className="min-h-[34px] resize-y text-xs"
|
|
rows={1}
|
|
value={it.customer_request ?? ""}
|
|
onChange={(e) => updateItem(idx, "customer_request", e.target.value)} />
|
|
</td>
|
|
<td className="p-1 text-center">
|
|
<Button variant="ghost" size="icon" onClick={() => removeItem(idx)} disabled={isContractConfirmed}>
|
|
<Trash2 className="w-3 h-3" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{(!form.items || form.items.length === 0) && (
|
|
<tr><td colSpan={10} className="p-3 text-center text-muted-foreground">품목이 없습니다.</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button size="sm" variant="outline" onClick={addItem} disabled={isContractConfirmed}>
|
|
<Plus className="w-3 h-3 mr-1" />품목 추가
|
|
</Button>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>취소</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 메일 발송 Dialog — wace estimateMailFormPopup.jsp 1:1 */}
|
|
<EstimateMailDialog
|
|
open={mailDialogOpen}
|
|
onOpenChange={setMailDialogOpen}
|
|
contractObjid={selected?.objid ?? null}
|
|
addEstCount={Number((selected as any)?.add_est_cnt ?? 0)}
|
|
estStatusCount={Number(selected?.est_status ?? 0)}
|
|
onSent={() => { fetchList(); }}
|
|
/>
|
|
|
|
{/* 첨부파일 다이얼로그 — 추가견적 등 클립 컬럼 클릭 시 */}
|
|
{attachContext && (
|
|
<AttachmentDialog
|
|
open={attachDialogOpen}
|
|
onOpenChange={setAttachDialogOpen}
|
|
targetObjid={attachContext.targetObjid}
|
|
docType={attachContext.docType}
|
|
uploadDocType={attachContext.uploadDocType}
|
|
uploadDocTypeName={attachContext.uploadDocTypeName}
|
|
title={attachContext.title}
|
|
onChanged={fetchList}
|
|
/>
|
|
)}
|
|
|
|
{/* G5: 견적작성 — 일반/장비 선택 (wace estimateList_new.jsp Swal 105~106) */}
|
|
<Dialog open={templateChoiceOpen} onOpenChange={setTemplateChoiceOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>견적서 작성</DialogTitle>
|
|
<DialogDescription>작성할 견적서 종류를 선택하세요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-3 justify-center py-4">
|
|
<Button variant="default" size="lg" onClick={() => pickTemplate("1")}>일반 견적서</Button>
|
|
<Button size="lg" onClick={() => pickTemplate("2")} style={{ backgroundColor: "#28a745" }}>장비 견적서</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* G5: 견적 차수 리스트 (wace fn_showEstimateList) */}
|
|
<Dialog open={templateListOpen} onOpenChange={setTemplateListOpen}>
|
|
<DialogContent className="sm:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>견적 차수 리스트 — {selected?.contract_no ?? ""}</DialogTitle>
|
|
<DialogDescription>차수를 선택하면 해당 견적서를 새 창으로 엽니다.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="max-h-[60vh] overflow-y-auto">
|
|
<table className="w-full text-sm border-collapse">
|
|
<thead>
|
|
<tr className="bg-muted">
|
|
<th className="border px-2 py-1.5 text-center w-[60px]">차수</th>
|
|
<th className="border px-2 py-1.5 text-center w-[80px]">종류</th>
|
|
<th className="border px-2 py-1.5">견적번호</th>
|
|
<th className="border px-2 py-1.5 text-right w-[120px]">공급가액</th>
|
|
<th className="border px-2 py-1.5 text-center w-[140px]">작성일시</th>
|
|
<th className="border px-2 py-1.5 text-center w-[80px]">작성자</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{templateList.length === 0 ? (
|
|
<tr><td colSpan={6} className="text-center py-6 text-muted-foreground">등록된 견적서가 없습니다.</td></tr>
|
|
) : templateList.map((t, i) => (
|
|
<tr key={t.objid}
|
|
className="cursor-pointer hover:bg-accent"
|
|
onClick={() => openExistingTemplate(t.objid, t.template_type)}>
|
|
<td className="border px-2 py-1.5 text-center">{templateList.length - i}차</td>
|
|
<td className="border px-2 py-1.5 text-center">{t.template_type === "2" ? "장비" : "일반"}</td>
|
|
<td className="border px-2 py-1.5">{t.estimate_no ?? ""}</td>
|
|
<td className="border px-2 py-1.5 text-right">{t.total_amount ? Number(String(t.total_amount).replace(/,/g, "")).toLocaleString() : ""}</td>
|
|
<td className="border px-2 py-1.5 text-center">{t.regdate ?? ""}</td>
|
|
<td className="border px-2 py-1.5 text-center">{t.writer ?? ""}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 품목 검색 — 라인 클릭 시 해당 라인에 part_objid/part_no/part_name 채움 (단일 선택) */}
|
|
<ItemSearchDialog
|
|
open={itemDialogOpen}
|
|
onOpenChange={(o) => { setItemDialogOpen(o); if (!o) setItemSearchTargetIdx(null); }}
|
|
onSelect={(items: ItemRow[]) => {
|
|
if (!items || items.length === 0) return;
|
|
const it = items[0];
|
|
if (itemSearchTargetIdx === null) {
|
|
// 라인이 비어있으면 새 라인으로 추가
|
|
setForm((prev) => ({
|
|
...prev,
|
|
items: [...(prev.items ?? []), {
|
|
...EMPTY_ITEM,
|
|
seq: (prev.items?.length ?? 0) + 1,
|
|
part_objid: String(it.objid ?? ""),
|
|
part_no: it.item_number ?? it.item_code ?? "",
|
|
part_name: it.item_name ?? "",
|
|
}],
|
|
}));
|
|
} else {
|
|
const idx = itemSearchTargetIdx;
|
|
setForm((prev) => {
|
|
const items = [...(prev.items ?? [])];
|
|
items[idx] = {
|
|
...items[idx],
|
|
part_objid: String(it.objid ?? ""),
|
|
part_no: it.item_number ?? it.item_code ?? "",
|
|
part_name: it.item_name ?? "",
|
|
};
|
|
return { ...prev, items };
|
|
});
|
|
}
|
|
setItemSearchTargetIdx(null);
|
|
}}
|
|
/>
|
|
|
|
{/* S/N 관리 — wace fn_openItemSnPopup (테이블 + 연속번호생성) */}
|
|
<Dialog open={serialDialogOpen} onOpenChange={setSerialDialogOpen}>
|
|
<DialogContent className="max-w-2xl" onInteractOutside={(e) => e.preventDefault()}>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-center">S/N 관리</DialogTitle>
|
|
<DialogDescription className="sr-only">시리얼 번호 추가/삭제 및 연속번호 생성</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
{/* S/N 목록 테이블 */}
|
|
<div className="max-h-[300px] overflow-y-auto border rounded">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted/50">
|
|
<tr>
|
|
<th className="border px-3 py-2 w-16 text-center">번호</th>
|
|
<th className="border px-3 py-2 text-center">S/N</th>
|
|
<th className="border px-3 py-2 w-20 text-center">삭제</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{serialDraft.length === 0 ? (
|
|
<tr><td colSpan={3} className="border px-3 py-8 text-center text-muted-foreground">등록된 S/N이 없습니다.</td></tr>
|
|
) : serialDraft.map((s, i) => (
|
|
<tr key={i}>
|
|
<td className="border px-3 py-1.5 text-center">{i + 1}</td>
|
|
<td className="border px-3 py-1.5">{s}</td>
|
|
<td className="border px-3 py-1.5 text-center">
|
|
<Button size="sm" variant="outline" onClick={() => removeSerialDraft(i)}>삭제</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/* S/N 입력 + 추가 */}
|
|
<div className="flex gap-2">
|
|
<Input value={serialInput} onChange={(e) => setSerialInput(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSerialDraft(); } }}
|
|
placeholder="S/N 입력" />
|
|
<Button onClick={addSerialDraft} type="button">추가</Button>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="sm:justify-center">
|
|
<Button variant="outline" onClick={openSeqDialog}>연속번호생성</Button>
|
|
<Button onClick={applySerialDraft}>확인</Button>
|
|
<Button variant="outline" onClick={() => setSerialDialogOpen(false)}>취소</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 연속번호 생성 — wace fn_openItemSequentialSnPopup */}
|
|
<Dialog open={seqDialogOpen} onOpenChange={setSeqDialogOpen}>
|
|
<DialogContent className="max-w-sm" onInteractOutside={(e) => e.preventDefault()}>
|
|
<DialogHeader>
|
|
<DialogTitle>연속번호 생성</DialogTitle>
|
|
<DialogDescription className="sr-only">시작번호와 생성개수로 연속 S/N 일괄 추가</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="text-xs">시작번호 <span className="text-rose-600">*</span></Label>
|
|
<Input value={seqStartNo} onChange={(e) => setSeqStartNo(e.target.value)}
|
|
placeholder="예: ITEM-001" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">생성개수 <span className="text-rose-600">*</span></Label>
|
|
<Input type="number" min={1} max={100}
|
|
value={seqCount} onChange={(e) => setSeqCount(e.target.value)}
|
|
placeholder="예: 10" />
|
|
</div>
|
|
<div className="bg-muted/40 rounded p-2 text-[11px] leading-5 text-muted-foreground">
|
|
예: ITEM-001, 개수 3 → ITEM-001, ITEM-002, ITEM-003<br />
|
|
※ 최대 100개까지 생성 가능
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setSeqDialogOpen(false)}>취소</Button>
|
|
<Button onClick={generateSequentialSn}>생성</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|