영업관리 첨부파일 모달·주문서 뷰 + 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:
hjjeong
2026-05-08 14:48:13 +09:00
parent 489fa50d11
commit 2d9f30ebab
12 changed files with 936 additions and 31 deletions
+11 -2
View File
@@ -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]}`;
}
+70 -16
View File
@@ -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]}`;
}
+4
View File
@@ -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: [] };
},
};