영업관리 첨부파일 모달·주문서 뷰 + DataGrid frozen 컬럼
- 공통 AttachmentDialog 컴포넌트 신설 (목록/업로드/다운로드/삭제, doc_type 단일·배열 지원) - 견적 add_est_cnt(클립) → AttachmentDialog (estimate02) - 주문 cu01_cnt(클립) → AttachmentDialog (FTC_ORDER,ORDER), has_order_data(폴더) → OrderFormViewDialog - OrderFormViewDialog 신설 — wace orderFormView 한국 표준 주문서 양식 + 인쇄 - 백엔드 fileController.getFileList docType 콤마 멀티값 지원, salesOrderMgmt.getOrderFormView API 추가 - DataGrid 확장: column.onClick / frozen prop / table-fixed / sticky-left + selected·hover 일관 처리 - 4개 메뉴 첫 컬럼 frozen 적용 (영업번호/프로젝트번호) - 주문서관리 발주일·발주번호·요청납기 컬럼 너비 확장 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -601,8 +601,17 @@ export const getFileList = async (
|
||||
}
|
||||
|
||||
if (docType) {
|
||||
whereConditions.push(`doc_type = $${paramIndex}`);
|
||||
queryParams.push(docType as string);
|
||||
const docTypes = String(docType)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (docTypes.length === 1) {
|
||||
whereConditions.push(`doc_type = $${paramIndex}`);
|
||||
queryParams.push(docTypes[0]);
|
||||
} else if (docTypes.length > 1) {
|
||||
whereConditions.push(`doc_type = ANY($${paramIndex}::text[])`);
|
||||
queryParams.push(docTypes);
|
||||
}
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,15 @@ export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||
} catch (e: any) { logger.error("주문서 삭제 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); }
|
||||
}
|
||||
|
||||
export async function getFormView(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const data = await svc.getOrderFormView(id);
|
||||
if (!data) return res.status(404).json({ success: false, message: "주문서를 찾을 수 없습니다." });
|
||||
return res.json({ success: true, data });
|
||||
} catch (e: any) { logger.error("주문서 뷰 조회 실패", { error: e.message }); return res.status(500).json({ success: false, message: e.message }); }
|
||||
}
|
||||
|
||||
export async function updateStatus(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
|
||||
@@ -7,6 +7,7 @@ router.use(authenticateToken);
|
||||
|
||||
router.get("/list", ctrl.getList);
|
||||
router.get("/generate-number", ctrl.generateNumber);
|
||||
router.get("/:id/form-view", ctrl.getFormView);
|
||||
router.get("/:id", ctrl.getById);
|
||||
router.post("/", ctrl.create);
|
||||
router.put("/:id", ctrl.update);
|
||||
|
||||
@@ -270,6 +270,80 @@ export async function getById(objid: string) {
|
||||
return { ...headerRes.rows[0], items };
|
||||
}
|
||||
|
||||
// ─── 주문서 뷰 (wace orderFormView 대응) ──────────────────────
|
||||
// wace 패턴: getOrderFormInfo(헤더 + 거래처) + getOrderFormItems(라인) 두 쿼리.
|
||||
|
||||
export async function getOrderFormView(objid: string) {
|
||||
const pool = getPool();
|
||||
const headerSql = `
|
||||
SELECT
|
||||
T.objid AS objid,
|
||||
T.contract_no AS contract_no,
|
||||
T.po_no AS po_no,
|
||||
T.order_date AS order_date,
|
||||
T.customer_objid AS customer_objid,
|
||||
CC_CAT.code_name AS category_name,
|
||||
CC_CUR.code_name AS currency_name,
|
||||
T.contract_currency AS currency_code,
|
||||
T.exchange_rate AS exchange_rate,
|
||||
T.order_supply_price AS order_supply_price,
|
||||
T.order_vat AS order_vat,
|
||||
T.order_total_amount AS order_total_amount,
|
||||
CASE
|
||||
WHEN T.paid_type = 'paid' THEN '부가세별도'
|
||||
WHEN T.paid_type = 'free' THEN '부가세미포함'
|
||||
ELSE ''
|
||||
END AS vat_note,
|
||||
T.customer_request AS customer_request,
|
||||
C.customer_name AS client_nm,
|
||||
C.business_number AS client_bus_reg_no,
|
||||
COALESCE(C.ceo_name, C.representative_name) AS client_ceo_nm,
|
||||
C.address AS client_addr,
|
||||
C.biz_condition AS client_bus_type,
|
||||
C.biz_item AS client_bus_item,
|
||||
COALESCE(C.tel, C.phone, C.contact_phone) AS client_tel_no,
|
||||
COALESCE(C.fax_no, C.fax) AS client_fax_no,
|
||||
COALESCE(C.email, C.charge_email) AS client_email,
|
||||
TRIM(BOTH FROM COALESCE(U_WR.dept_name, '') || ' ' || COALESCE(U_WR.user_name, T.writer)) AS writer_name,
|
||||
U_WR.tel AS writer_contact,
|
||||
TO_CHAR(T.regdate, 'YYYY-MM-DD HH24:MI:SS') AS reg_datetime
|
||||
FROM contract_mgmt T
|
||||
LEFT JOIN customer_mng C
|
||||
ON C.customer_code = CASE WHEN T.customer_objid LIKE 'C_%' THEN substring(T.customer_objid, 3) ELSE T.customer_objid END
|
||||
LEFT JOIN user_info U_WR ON U_WR.user_id = T.writer
|
||||
LEFT JOIN comm_code CC_CAT ON CC_CAT.code_id = T.category_cd AND CC_CAT.status='active'
|
||||
LEFT JOIN comm_code CC_CUR ON CC_CUR.code_id = T.contract_currency AND CC_CUR.status='active'
|
||||
WHERE T.objid = $1
|
||||
`;
|
||||
const headerRes = await pool.query(headerSql, [objid]);
|
||||
if (headerRes.rowCount === 0) return null;
|
||||
|
||||
const itemsSql = `
|
||||
SELECT
|
||||
CI.seq,
|
||||
COALESCE(IT.item_number, CI.part_no) AS part_no,
|
||||
COALESCE(IT.item_name, CI.part_name) AS part_name,
|
||||
IT.size AS spec,
|
||||
CC_UNIT.code_name AS unit_name,
|
||||
IT.unit AS unit_code,
|
||||
CI.due_date AS due_date,
|
||||
CI.order_quantity AS order_quantity,
|
||||
CI.order_unit_price AS order_unit_price,
|
||||
CI.order_supply_price AS order_supply_price,
|
||||
CI.order_vat AS order_vat,
|
||||
CI.order_total_amount AS order_total_amount
|
||||
FROM contract_item CI
|
||||
LEFT JOIN item_info IT ON IT.id = CI.part_objid
|
||||
LEFT JOIN comm_code CC_UNIT ON CC_UNIT.code_id = IT.unit AND CC_UNIT.status='active'
|
||||
WHERE CI.contract_objid = $1 AND CI.status = 'ACTIVE'
|
||||
ORDER BY CI.seq
|
||||
`;
|
||||
const itemsRes = await pool.query(itemsSql, [objid]);
|
||||
|
||||
logger.info("주문서 뷰 조회", { objid, itemCount: itemsRes.rowCount });
|
||||
return { info: headerRes.rows[0], items: itemsRes.rows };
|
||||
}
|
||||
|
||||
// ─── 영업번호 채번 ─────────────────────────────────────────────
|
||||
|
||||
export async function generateContractNo(): Promise<string> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
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";
|
||||
@@ -20,6 +20,7 @@ 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 { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate";
|
||||
import { salesOrderMgmtApi } from "@/lib/api/salesOrderMgmt";
|
||||
|
||||
@@ -27,7 +28,7 @@ import { salesOrderMgmtApi } from "@/lib/api/salesOrderMgmt";
|
||||
|
||||
// wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "contract_no", label: "영업번호", width: "w-[110px]" },
|
||||
{ key: "contract_no", label: "영업번호", width: "w-[110px]", frozen: true },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" },
|
||||
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[110px]", align: "center" },
|
||||
@@ -111,6 +112,39 @@ export default function SalesEstimatePage() {
|
||||
contents: "",
|
||||
});
|
||||
|
||||
// 첨부파일 다이얼로그 (추가견적 클립 컬럼 클릭 시)
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
return col;
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// ─── 데이터 로드 ────────────────────────────────────────────
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
@@ -452,7 +486,7 @@ export default function SalesEstimatePage() {
|
||||
|
||||
{/* 그리드 */}
|
||||
<DataGrid
|
||||
columns={GRID_COLUMNS}
|
||||
columns={gridColumns}
|
||||
data={rows}
|
||||
selectedId={selected ? selected.objid : null}
|
||||
onSelect={(id) => {
|
||||
@@ -723,6 +757,20 @@ export default function SalesEstimatePage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 첨부파일 다이얼로그 — 추가견적 등 클립 컬럼 클릭 시 */}
|
||||
{attachContext && (
|
||||
<AttachmentDialog
|
||||
open={attachDialogOpen}
|
||||
onOpenChange={setAttachDialogOpen}
|
||||
targetObjid={attachContext.targetObjid}
|
||||
docType={attachContext.docType}
|
||||
uploadDocType={attachContext.uploadDocType}
|
||||
uploadDocTypeName={attachContext.uploadDocTypeName}
|
||||
title={attachContext.title}
|
||||
onChanged={fetchList}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 품목 검색 — 등록 다이얼로그 */}
|
||||
<ItemSearchDialog
|
||||
open={itemDialogOpen}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
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";
|
||||
@@ -20,15 +20,17 @@ 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 { OrderFormViewDialog } from "@/components/sales/OrderFormViewDialog";
|
||||
import { salesOrderMgmtApi, OrderRow, OrderBody, OrderItem } from "@/lib/api/salesOrderMgmt";
|
||||
|
||||
// wace_plm orderMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "contract_no", label: "영업번호", width: "w-[120px]" },
|
||||
{ key: "contract_no", label: "영업번호", width: "w-[120px]", frozen: true },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[110px]" },
|
||||
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[110px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[120px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[130px]" },
|
||||
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" },
|
||||
{ key: "customer_name", label: "고객사", width: "w-[160px]" },
|
||||
{ key: "item_summary", label: "품명", width: "w-[200px]" },
|
||||
{ key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
@@ -39,8 +41,8 @@ const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "order_vat_sum", label: "부가세", width: "w-[100px]", formatMoney: true },
|
||||
{ key: "order_total_amount_sum", label: "총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "order_total_amount_krw", label: "원화총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", formatNumber: true },
|
||||
{ key: "has_order_data", label: "주문서", width: "w-[80px]", align: "center", formatNumber: true },
|
||||
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
|
||||
{ key: "has_order_data", label: "주문서", width: "w-[80px]", align: "center", renderType: "folder" },
|
||||
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
|
||||
{ key: "order_appr_status", label: "결재상태", width: "w-[90px]", align: "center" },
|
||||
{ key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
||||
@@ -89,6 +91,56 @@ export default function SalesOrderPage() {
|
||||
// 품목 검색 모달
|
||||
const [itemDialogOpen, setItemDialogOpen] = useState(false);
|
||||
|
||||
// 첨부파일 다이얼로그 (주문서첨부 클립 컬럼 클릭 시)
|
||||
const [attachDialogOpen, setAttachDialogOpen] = useState(false);
|
||||
const [attachContext, setAttachContext] = useState<{
|
||||
targetObjid: string;
|
||||
docType: string | string[];
|
||||
uploadDocType: string;
|
||||
uploadDocTypeName?: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
// 주문서 자동생성 뷰 다이얼로그 (has_order_data 폴더 컬럼 클릭)
|
||||
const [orderFormOpen, setOrderFormOpen] = useState(false);
|
||||
const [orderFormObjid, setOrderFormObjid] = useState<string | null>(null);
|
||||
|
||||
const gridColumns = useMemo<DataGridColumn[]>(
|
||||
() => GRID_COLUMNS.map((col) => {
|
||||
if (col.key === "cu01_cnt") {
|
||||
return {
|
||||
...col,
|
||||
onClick: (row) => {
|
||||
setAttachContext({
|
||||
targetObjid: String(row.objid),
|
||||
docType: ["FTC_ORDER", "ORDER"],
|
||||
uploadDocType: "ORDER",
|
||||
uploadDocTypeName: "주문서",
|
||||
title: `주문서 첨부 — ${row.contract_no ?? ""}`,
|
||||
});
|
||||
setAttachDialogOpen(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (col.key === "has_order_data") {
|
||||
return {
|
||||
...col,
|
||||
onClick: (row) => {
|
||||
const cnt = Number(row.has_order_data ?? 0);
|
||||
if (!cnt || cnt <= 0) {
|
||||
toast.info("주문 라인이 입력되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
setOrderFormObjid(String(row.objid));
|
||||
setOrderFormOpen(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
return col;
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
@@ -336,7 +388,7 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
columns={GRID_COLUMNS}
|
||||
columns={gridColumns}
|
||||
data={rows}
|
||||
selectedId={selected ? selected.objid : null}
|
||||
onSelect={(id) => setSelected(id ? rows.find((r) => r.id === id) ?? null : null)}
|
||||
@@ -460,6 +512,27 @@ export default function SalesOrderPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 첨부파일 다이얼로그 — 주문서첨부 클립 컬럼 클릭 시 */}
|
||||
{attachContext && (
|
||||
<AttachmentDialog
|
||||
open={attachDialogOpen}
|
||||
onOpenChange={setAttachDialogOpen}
|
||||
targetObjid={attachContext.targetObjid}
|
||||
docType={attachContext.docType}
|
||||
uploadDocType={attachContext.uploadDocType}
|
||||
uploadDocTypeName={attachContext.uploadDocTypeName}
|
||||
title={attachContext.title}
|
||||
onChanged={fetchList}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 주문서 자동생성 뷰 — 주문서 폴더 컬럼 클릭 시 (wace orderFormView 대응) */}
|
||||
<OrderFormViewDialog
|
||||
open={orderFormOpen}
|
||||
onOpenChange={setOrderFormOpen}
|
||||
objid={orderFormObjid}
|
||||
/>
|
||||
|
||||
{/* 품목 검색 — 등록 다이얼로그 (다중 선택) */}
|
||||
<ItemSearchDialog
|
||||
open={itemDialogOpen}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { salesSaleApi, RevenueListRow, DeadlineInfoBody } from "@/lib/api/salesS
|
||||
|
||||
// wace_plm revenueMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]", frozen: true },
|
||||
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "sales_deadline_date", label: "매출마감", width: "w-[110px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
|
||||
|
||||
@@ -21,7 +21,7 @@ import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale
|
||||
|
||||
// wace_plm salesMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]", frozen: true },
|
||||
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[110px]" },
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AttachmentDialog — 공통 첨부파일 모달
|
||||
*
|
||||
* 어디서나 재사용. attach_file_info(target_objid, doc_type) 기반.
|
||||
* - 목록 조회 / 다운로드 / 업로드(다중) / 삭제
|
||||
* - readOnly=true 면 조회 전용
|
||||
* - onChanged 콜백으로 그리드 카운트 갱신 트리거
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Download, Paperclip, Trash2, Upload } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AttachmentFile {
|
||||
objid: string;
|
||||
realFileName: string;
|
||||
savedFileName: string;
|
||||
fileSize: number;
|
||||
fileExt: string;
|
||||
filePath: string;
|
||||
docType: string;
|
||||
docTypeName?: string;
|
||||
writer: string;
|
||||
regdate: string;
|
||||
}
|
||||
|
||||
export interface AttachmentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** attach_file_info.target_objid (보통 contract_mgmt.objid 등) */
|
||||
targetObjid: string | number | null | undefined;
|
||||
/** 조회 시 doc_type 필터. 단일 문자열 또는 배열(예: ["FTC_ORDER","ORDER"]) */
|
||||
docType: string | string[];
|
||||
/** 새 업로드 시 INSERT할 doc_type. 미지정 시 docType 단일/배열의 첫 값 사용 */
|
||||
uploadDocType?: string;
|
||||
/** docType_name 컬럼에 저장할 한글 라벨 (선택) */
|
||||
uploadDocTypeName?: string;
|
||||
title?: string;
|
||||
/** 업로드/삭제 버튼 비활성화 (조회 전용) */
|
||||
readOnly?: boolean;
|
||||
/** 업로드/삭제 후 호출 — 그리드 카운트 갱신용 */
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
export function AttachmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetObjid,
|
||||
docType,
|
||||
uploadDocType,
|
||||
uploadDocTypeName,
|
||||
title = "첨부파일",
|
||||
readOnly = false,
|
||||
onChanged,
|
||||
}: AttachmentDialogProps) {
|
||||
const [files, setFiles] = useState<AttachmentFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const docTypeQuery = Array.isArray(docType) ? docType.join(",") : docType;
|
||||
const docTypeForUpload =
|
||||
uploadDocType ?? (Array.isArray(docType) ? docType[0] : docType);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
if (!targetObjid) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get("/files", {
|
||||
params: { targetObjid: String(targetObjid), docType: docTypeQuery },
|
||||
});
|
||||
if (res.data?.success) {
|
||||
setFiles(res.data.files || []);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error("파일 목록 조회 실패: " + (e?.message ?? ""));
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [targetObjid, docTypeQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadFiles();
|
||||
}
|
||||
}, [open, loadFiles]);
|
||||
|
||||
async function handleUpload(filesToUpload: FileList | null) {
|
||||
if (!filesToUpload || filesToUpload.length === 0) return;
|
||||
if (!targetObjid) {
|
||||
toast.error("대상 ID(targetObjid)가 없습니다.");
|
||||
return;
|
||||
}
|
||||
if (!docTypeForUpload) {
|
||||
toast.error("업로드 doc_type을 결정할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
for (let i = 0; i < filesToUpload.length; i++) {
|
||||
fd.append("files", filesToUpload[i]);
|
||||
}
|
||||
fd.append("targetObjid", String(targetObjid));
|
||||
fd.append("docType", docTypeForUpload);
|
||||
if (uploadDocTypeName) fd.append("docTypeName", uploadDocTypeName);
|
||||
|
||||
const res = await apiClient.post("/files/upload", fd, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
if (res.data?.success) {
|
||||
toast.success(`${filesToUpload.length}건 업로드 완료`);
|
||||
await loadFiles();
|
||||
onChanged?.();
|
||||
} else {
|
||||
toast.error(res.data?.message || "업로드 실패");
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error("업로드 실패: " + (e?.message ?? ""));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(objid: string, name: string) {
|
||||
if (!window.confirm(`'${name}' 파일을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
await apiClient.delete(`/files/${objid}`);
|
||||
toast.success("파일이 삭제되었습니다.");
|
||||
await loadFiles();
|
||||
onChanged?.();
|
||||
} catch (e: any) {
|
||||
toast.error("삭제 실패: " + (e?.message ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(objid: string) {
|
||||
const baseURL = (apiClient.defaults.baseURL ?? "").replace(/\/$/, "");
|
||||
window.open(`${baseURL}/files/download/${objid}`, "_blank");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-md max-h-[50vh] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">#</TableHead>
|
||||
<TableHead>파일명</TableHead>
|
||||
<TableHead className="w-[100px] text-right">크기</TableHead>
|
||||
<TableHead className="w-[110px] text-center">등록자</TableHead>
|
||||
<TableHead className="w-[150px] text-center">등록일</TableHead>
|
||||
<TableHead className="w-[110px] text-center">동작</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-6">
|
||||
로딩 중…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && files.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-6">
|
||||
첨부파일이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading &&
|
||||
files.map((f, i) => (
|
||||
<TableRow key={f.objid}>
|
||||
<TableCell className="text-center">{i + 1}</TableCell>
|
||||
<TableCell className="truncate max-w-[260px]" title={f.realFileName}>
|
||||
{f.realFileName}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatBytes(f.fileSize)}</TableCell>
|
||||
<TableCell className="text-center">{f.writer}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{f.regdate ? f.regdate.replace("T", " ").slice(0, 16) : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(f.objid)}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(f.objid, f.realFileName)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex sm:justify-between gap-2">
|
||||
{!readOnly ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => handleUpload(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading || !targetObjid}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
{uploading ? "업로드 중..." : "파일 추가"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB"];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
|
||||
}
|
||||
@@ -46,6 +46,10 @@ export interface DataGridColumn {
|
||||
/** 이미지 타입 — 값을 /api/files/preview/{objid} 이미지로 렌더링 */
|
||||
renderType?: "image" | "folder" | "clip";
|
||||
selectOptions?: { value: string; label: string }[];
|
||||
/** 셀 클릭 핸들러 — folder/clip 등 인터랙션 컬럼에서 모달 오픈용. 행 클릭으로 전파되지 않음 */
|
||||
onClick?: (row: any) => void;
|
||||
/** 좌측 고정 컬럼 (가로 스크롤 시 sticky-left). 첫 컬럼에만 사용 권장 */
|
||||
frozen?: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridProps {
|
||||
@@ -94,6 +98,7 @@ const fmtMoney = (val: any) => {
|
||||
function SortableHeaderCell({
|
||||
col, sortKey, sortDir, onSort,
|
||||
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
|
||||
frozenLeftClass = "left-0",
|
||||
}: {
|
||||
col: DataGridColumn;
|
||||
sortKey: string | null;
|
||||
@@ -103,6 +108,7 @@ function SortableHeaderCell({
|
||||
uniqueValues: string[];
|
||||
onToggleFilter: (colKey: string, value: string) => void;
|
||||
onClearFilter: (colKey: string) => void;
|
||||
frozenLeftClass?: string;
|
||||
}) {
|
||||
const [filterSearch, setFilterSearch] = useState("");
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
@@ -121,7 +127,10 @@ function SortableHeaderCell({
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(col.width, col.minWidth, "select-none relative")}
|
||||
className={cn(
|
||||
col.width, col.minWidth, "select-none relative",
|
||||
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div
|
||||
@@ -501,12 +510,17 @@ export function DataGrid({
|
||||
if (col.renderType === "folder") {
|
||||
const cnt = Number(val);
|
||||
const hasValue = !isNaN(cnt) && cnt > 0;
|
||||
const clickable = !!col.onClick;
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-full">
|
||||
<span
|
||||
className={cn("inline-flex items-center justify-center w-full", clickable && "cursor-pointer")}
|
||||
onClick={clickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined}
|
||||
>
|
||||
<Folder
|
||||
className={cn("w-5 h-5", hasValue
|
||||
? "fill-[#1a73e8] text-[#1a73e8]"
|
||||
: "fill-white text-muted-foreground/60")}
|
||||
: "fill-white text-muted-foreground/60",
|
||||
clickable && "hover:opacity-70")}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
@@ -516,9 +530,15 @@ export function DataGrid({
|
||||
if (col.renderType === "clip") {
|
||||
const cnt = Number(val);
|
||||
const hasValue = !isNaN(cnt) && cnt > 0;
|
||||
const clickable = !!col.onClick;
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center gap-1 w-full">
|
||||
<Paperclip className={cn("w-4 h-4", hasValue ? "text-[#1a73e8]" : "text-muted-foreground/50")} />
|
||||
<span
|
||||
className={cn("inline-flex items-center justify-center gap-1 w-full", clickable && "cursor-pointer")}
|
||||
onClick={clickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined}
|
||||
>
|
||||
<Paperclip className={cn("w-4 h-4",
|
||||
hasValue ? "text-[#1a73e8]" : "text-muted-foreground/50",
|
||||
clickable && "hover:opacity-70")} />
|
||||
{hasValue && <span className="text-[#1a73e8] font-bold text-xs">{cnt}</span>}
|
||||
</span>
|
||||
);
|
||||
@@ -540,16 +560,23 @@ export function DataGrid({
|
||||
);
|
||||
};
|
||||
|
||||
// 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치
|
||||
const hasFrozen = columns.some((c) => c.frozen);
|
||||
const hasFirstCol = showCheckbox || showRowNumber;
|
||||
const stickyFirstColClass = "sticky left-0 z-20 bg-background";
|
||||
const stickyFirstColBodyClass = "sticky left-0 z-[6]";
|
||||
const frozenLeftClass = hasFirstCol ? "left-10" : "left-0";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table noWrapper>
|
||||
<Table noWrapper className="table-fixed">
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<TableHead className={cn("w-[40px] text-center", hasFrozen && stickyFirstColClass)}>
|
||||
<Checkbox
|
||||
checked={processedData.length > 0 && checkedIds.length === processedData.length}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -558,7 +585,9 @@ export function DataGrid({
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{showRowNumber && !showCheckbox && <TableHead className="w-[40px] text-center text-xs">No</TableHead>}
|
||||
{showRowNumber && !showCheckbox && (
|
||||
<TableHead className={cn("w-[40px] text-center text-xs", hasFrozen && stickyFirstColClass)}>No</TableHead>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<SortableHeaderCell
|
||||
key={col.key}
|
||||
@@ -570,6 +599,7 @@ export function DataGrid({
|
||||
uniqueValues={columnUniqueValues[col.key] || []}
|
||||
onToggleFilter={toggleHeaderFilter}
|
||||
onClearFilter={clearHeaderFilter}
|
||||
frozenLeftClass={frozenLeftClass}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -588,12 +618,16 @@ export function DataGrid({
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginatedData.map((row, rowIdx) => (
|
||||
) : paginatedData.map((row, rowIdx) => {
|
||||
const isSelected = selectedId === row.id || (showCheckbox && checkedIds.includes(row.id));
|
||||
// sticky 셀에 alpha 없는 단색 배경 사용 (반투명이면 뒤 셀이 비침).
|
||||
// selected → bg-accent / hover(non-selected) → group-hover로 muted 적용 / 기본 → bg-background
|
||||
const stickyBgClass = isSelected ? "bg-accent" : "bg-background group-hover:bg-muted";
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id || rowIdx}
|
||||
className={cn("cursor-pointer",
|
||||
selectedId === row.id && "bg-primary/5",
|
||||
showCheckbox && checkedIds.includes(row.id) && "bg-primary/5",
|
||||
className={cn("cursor-pointer group",
|
||||
isSelected && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect?.(row.id);
|
||||
@@ -609,7 +643,14 @@ export function DataGrid({
|
||||
onDoubleClick={() => onRowDoubleClick?.(row, rowIdx)}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-center",
|
||||
isSelected && "bg-accent",
|
||||
hasFrozen && cn(stickyFirstColBodyClass, stickyBgClass),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(row.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -621,11 +662,24 @@ export function DataGrid({
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{pageOffset + rowIdx + 1}</TableCell>}
|
||||
{showRowNumber && !showCheckbox && (
|
||||
<TableCell className={cn(
|
||||
"text-center text-[10px] text-muted-foreground",
|
||||
isSelected && "bg-accent",
|
||||
hasFrozen && cn(stickyFirstColBodyClass, stickyBgClass),
|
||||
)}>
|
||||
{pageOffset + rowIdx + 1}
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(col.width, col.minWidth, "py-2.5", col.editable && "cursor-text")}
|
||||
className={cn(
|
||||
col.width, col.minWidth, "py-2.5",
|
||||
col.editable && "cursor-text",
|
||||
isSelected && "bg-accent",
|
||||
col.frozen && cn("sticky z-[5]", frozenLeftClass, stickyBgClass),
|
||||
)}
|
||||
onDoubleClick={(e) => {
|
||||
if (col.editable) {
|
||||
e.stopPropagation();
|
||||
@@ -637,7 +691,7 @@ export function DataGrid({
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
);})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* OrderFormViewDialog — 주문서 자동생성 뷰 (wace orderFormView.jsp 대응)
|
||||
*
|
||||
* 주문관리 그리드 "주문서" 폴더 컬럼 클릭 시 표시.
|
||||
* 한국 표준 주문서 양식 (공급받는자/공급자/품목/합계).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Printer } from "lucide-react";
|
||||
import { salesOrderMgmtApi } from "@/lib/api/salesOrderMgmt";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 공급자(우리 회사) 정보 — wace 원본 하드코딩값. 추후 회사 마스터 테이블로 이전.
|
||||
const SUPPLIER = {
|
||||
busRegNo: "314-81-75146",
|
||||
name: "주식회사알피에스본사",
|
||||
ceo: "이동헌",
|
||||
address: "대전광역시 유성구 국제과학10로 8(둔곡동)",
|
||||
busType: "제조업",
|
||||
busItem: "금속절삭가공기계,반도체제조용기계",
|
||||
};
|
||||
|
||||
interface OrderFormInfo {
|
||||
objid: string;
|
||||
contract_no: string;
|
||||
po_no: string;
|
||||
order_date: string;
|
||||
client_nm?: string;
|
||||
client_bus_reg_no?: string;
|
||||
client_ceo_nm?: string;
|
||||
client_addr?: string;
|
||||
client_bus_type?: string;
|
||||
client_bus_item?: string;
|
||||
client_tel_no?: string;
|
||||
client_fax_no?: string;
|
||||
client_email?: string;
|
||||
writer_name?: string;
|
||||
writer_contact?: string;
|
||||
order_supply_price?: string | number;
|
||||
order_vat?: string | number;
|
||||
order_total_amount?: string | number;
|
||||
vat_note?: string;
|
||||
reg_datetime?: string;
|
||||
}
|
||||
|
||||
interface OrderFormItem {
|
||||
seq: number;
|
||||
part_no?: string;
|
||||
part_name?: string;
|
||||
spec?: string;
|
||||
unit_name?: string;
|
||||
due_date?: string;
|
||||
order_quantity?: string | number;
|
||||
order_unit_price?: string | number;
|
||||
order_supply_price?: string | number;
|
||||
order_vat?: string | number;
|
||||
order_total_amount?: string | number;
|
||||
}
|
||||
|
||||
export interface OrderFormViewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
objid: string | null | undefined;
|
||||
}
|
||||
|
||||
export function OrderFormViewDialog({ open, onOpenChange, objid }: OrderFormViewDialogProps) {
|
||||
const [info, setInfo] = useState<OrderFormInfo | null>(null);
|
||||
const [items, setItems] = useState<OrderFormItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !objid) {
|
||||
setInfo(null);
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
salesOrderMgmtApi
|
||||
.formView(String(objid))
|
||||
.then((data) => {
|
||||
setInfo(data.info);
|
||||
setItems(data.items ?? []);
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast.error("주문서 데이터 조회 실패: " + (e?.message ?? ""));
|
||||
setInfo(null);
|
||||
setItems([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, objid]);
|
||||
|
||||
const orderDateText = formatOrderDate(info?.order_date);
|
||||
const totalQty = items.reduce((acc, it) => acc + toNum(it.order_quantity), 0);
|
||||
const totalSupply = items.reduce((acc, it) => acc + toNum(it.order_supply_price), 0);
|
||||
|
||||
function handlePrint() {
|
||||
const node = document.getElementById("order-form-print-area");
|
||||
if (!node) return;
|
||||
const w = window.open("", "_blank", "width=950,height=800");
|
||||
if (!w) return;
|
||||
w.document.write(`
|
||||
<html><head><title>주문서</title>
|
||||
<style>
|
||||
body{font-family:'맑은 고딕',sans-serif;margin:18px;color:#000;font-size:12px;}
|
||||
.order-title{text-align:center;font-size:24px;font-weight:bold;letter-spacing:18px;margin:8px 0 14px;}
|
||||
.header-row{font-size:11px;margin-bottom:2px;}
|
||||
table{border-collapse:collapse;width:100%;}
|
||||
td,th{border:1px solid #000;padding:3px 5px;}
|
||||
.lbl{background:#f3f3f3;text-align:center;font-weight:bold;}
|
||||
.vl{background:#e8e8e8;text-align:center;font-weight:bold;font-size:13px;}
|
||||
.tc{text-align:center;}
|
||||
.tr{text-align:right;}
|
||||
.item-tbl thead th{background:#fff8c5;font-weight:bold;text-align:center;}
|
||||
.total-row td{background:#ffffcc;font-weight:bold;text-align:center;letter-spacing:8px;}
|
||||
</style></head><body>${node.innerHTML}</body></html>
|
||||
`);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
setTimeout(() => { w.print(); }, 250);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>주문서 — {info?.contract_no ?? ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center text-muted-foreground py-10">로딩 중…</div>
|
||||
)}
|
||||
|
||||
{!loading && !info && (
|
||||
<div className="text-center text-muted-foreground py-10">주문서 데이터가 없습니다.</div>
|
||||
)}
|
||||
|
||||
{!loading && info && (
|
||||
<div id="order-form-print-area" className="text-[11px] text-black bg-white p-3">
|
||||
<div className="text-center text-2xl font-bold tracking-[18px] my-2">주 문 서</div>
|
||||
<div className="text-[11px] mb-0.5">주문일자 : {orderDateText}</div>
|
||||
<div className="text-[11px] mb-1">증빙번호 : {info.po_no ?? ""}</div>
|
||||
|
||||
{/* 공급받는자 / 공급자 */}
|
||||
<table className="w-full border-collapse text-[11px] mt-1">
|
||||
<colgroup>
|
||||
<col style={{ width: "28px" }} />
|
||||
<col style={{ width: "62px" }} />
|
||||
<col />
|
||||
<col style={{ width: "45px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "28px" }} />
|
||||
<col style={{ width: "62px" }} />
|
||||
<col />
|
||||
<col style={{ width: "45px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={4} className="vl border border-black bg-gray-200 text-center font-bold" style={{ writingMode: "vertical-rl" as any }}>공급받는자</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">등록번호</td>
|
||||
<td colSpan={3} className="border border-black px-1.5">{info.client_bus_reg_no ?? ""}</td>
|
||||
<td rowSpan={4} className="vl border border-black bg-gray-200 text-center font-bold" style={{ writingMode: "vertical-rl" as any }}>공 급 자</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">등록번호</td>
|
||||
<td colSpan={3} className="border border-black px-1.5">{SUPPLIER.busRegNo}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">상 호</td>
|
||||
<td className="border border-black px-1.5">{info.client_nm ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">성명</td>
|
||||
<td className="border border-black px-1.5">{info.client_ceo_nm ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">상 호</td>
|
||||
<td className="border border-black px-1.5">{SUPPLIER.name}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">성명</td>
|
||||
<td className="border border-black px-1.5">{SUPPLIER.ceo}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">주 소</td>
|
||||
<td colSpan={3} className="border border-black px-1.5">{info.client_addr ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">주 소</td>
|
||||
<td colSpan={3} className="border border-black px-1.5">{SUPPLIER.address}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">업 태</td>
|
||||
<td className="border border-black px-1.5">{info.client_bus_type ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">종목</td>
|
||||
<td className="border border-black px-1.5">{info.client_bus_item ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">업 태</td>
|
||||
<td className="border border-black px-1.5">{SUPPLIER.busType}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">종목</td>
|
||||
<td className="border border-black px-1.5 text-[9px]">{SUPPLIER.busItem}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 납품처 / 담당자 */}
|
||||
<table className="w-full border-collapse text-[11px] -mt-px">
|
||||
<colgroup>
|
||||
<col style={{ width: "70px" }} />
|
||||
<col />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "130px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "130px" }} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">납 품 처</td>
|
||||
<td className="border border-black px-1.5">{info.client_nm ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">전화번호</td>
|
||||
<td className="border border-black px-1.5">{info.client_tel_no ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">팩스번호</td>
|
||||
<td className="border border-black px-1.5">{info.client_fax_no ?? ""}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">주 소</td>
|
||||
<td className="border border-black px-1.5">{info.client_addr ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">담 당 자</td>
|
||||
<td className="border border-black px-1.5">{info.writer_name ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">C.P.번호</td>
|
||||
<td className="border border-black px-1.5">{info.writer_contact ?? ""}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<table className="item-tbl w-full border-collapse text-[11px] -mt-px">
|
||||
<colgroup>
|
||||
<col style={{ width: "32px" }} />
|
||||
<col style={{ width: "85px" }} />
|
||||
<col />
|
||||
<col />
|
||||
<col style={{ width: "38px" }} />
|
||||
<col style={{ width: "78px" }} />
|
||||
<col style={{ width: "48px" }} />
|
||||
<col style={{ width: "68px" }} />
|
||||
<col style={{ width: "78px" }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{["No.", "품번", "품명", "규격", "단위", "납기일", "수량", "단가", "금액"].map((h) => (
|
||||
<th key={h} className="border border-black bg-yellow-50 font-bold text-center px-1.5 py-1">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && (
|
||||
<tr><td colSpan={9} className="border border-black text-center py-3 text-muted-foreground">라인 없음</td></tr>
|
||||
)}
|
||||
{items.map((it, i) => (
|
||||
<tr key={i}>
|
||||
<td className="border border-black tc text-center px-1">{i + 1}</td>
|
||||
<td className="border border-black tc text-center px-1">{it.part_no ?? ""}</td>
|
||||
<td className="border border-black px-1.5">{it.part_name ?? ""}</td>
|
||||
<td className="border border-black px-1.5">{it.spec ?? ""}</td>
|
||||
<td className="border border-black tc text-center px-1">{it.unit_name ?? ""}</td>
|
||||
<td className="border border-black tc text-center px-1">{it.due_date ?? ""}</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(it.order_quantity)}</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(it.order_unit_price)}</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(it.order_supply_price)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="total-row">
|
||||
<td colSpan={9} className="border border-black bg-yellow-100 text-center font-bold tracking-[8px] py-1">합 계</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={6} className="border border-black tc"></td>
|
||||
<td className="border border-black tr text-right px-1.5 font-bold">{fmt(totalQty)}</td>
|
||||
<td className="border border-black tr"></td>
|
||||
<td className="border border-black tr text-right px-1.5 font-bold">{fmt(totalSupply)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
{/* 비고 / 합계 요약 */}
|
||||
<table className="w-full border-collapse text-[11px] -mt-px">
|
||||
<colgroup>
|
||||
<col style={{ width: "40px" }} />
|
||||
<col />
|
||||
<col style={{ width: "120px" }} />
|
||||
<col style={{ width: "150px" }} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={3} className="vl border border-black bg-gray-200 text-center font-bold tracking-[8px] text-[13px]" style={{ writingMode: "vertical-rl" as any }}>비 고</td>
|
||||
<td rowSpan={3} className="border border-black"></td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold tracking-[2px]">공 급 가 액 합 계</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(info.order_supply_price)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold tracking-[2px]">부 가 가 치 세</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(info.order_vat)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold tracking-[5px]">총 계</td>
|
||||
<td className="border border-black tr text-right px-1.5 font-bold">{fmt(info.order_total_amount)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="flex justify-between border border-black border-t-0 px-1.5 py-1 text-[11px]">
|
||||
<span>{info.vat_note ?? ""}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex sm:justify-between gap-2 print:hidden">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint} disabled={!info}>
|
||||
<Printer className="h-4 w-4 mr-1" /> 인쇄
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function toNum(v: any): number {
|
||||
if (v == null || v === "") return 0;
|
||||
const n = Number(String(v).replace(/,/g, ""));
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function fmt(v: any): string {
|
||||
const n = toNum(v);
|
||||
if (n === 0) return "";
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatOrderDate(s?: string): string {
|
||||
if (!s) return "";
|
||||
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return s;
|
||||
return `${m[1]}년 ${m[2]}월 ${m[3]}일`;
|
||||
}
|
||||
@@ -123,4 +123,8 @@ export const salesOrderMgmtApi = {
|
||||
async setStatus(objid: string, contract_result: string) {
|
||||
return (await apiClient.patch(`/sales/order-mgmt/${objid}/status`, { contract_result })).data;
|
||||
},
|
||||
async formView(objid: string): Promise<{ info: any; items: any[] }> {
|
||||
const res = await apiClient.get(`/sales/order-mgmt/${objid}/form-view`);
|
||||
return res.data?.data ?? { info: null, items: [] };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user