From 5e778d30522f6e6ee90c42e834abdb8113d3880c Mon Sep 17 00:00:00 2001 From: pgb <gbpark@gdnsi.com> Date: Thu, 16 Jan 2025 17:45:54 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=A9=EB=8F=8C=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plm-api/src/routes/app.js | 2 + plm-api/src/services/contract.service.js | 114 ++++- .../common/codeCategoryMngList/page.tsx | 46 +- plm-app/src/app/(admin)/common/menu/page.tsx | 42 +- plm-app/src/components/common/Table/index.tsx | 401 +++++++++++++++--- 5 files changed, 496 insertions(+), 109 deletions(-) diff --git a/plm-api/src/routes/app.js b/plm-api/src/routes/app.js index 7dbc428..d95b427 100644 --- a/plm-api/src/routes/app.js +++ b/plm-api/src/routes/app.js @@ -21,6 +21,7 @@ const oemMngController = require("../controllers/app/common/oemmng.controller"); const productgroupController = require("../controllers/app/common/productgroup.controller"); const productController = require("../controllers/app/common/product.controller"); const carMngController = require("../controllers/app/common/carmng.controller"); +const contractController = require("../controllers/app/contract/contract.controller"); router.use("/health", healthController); router.use("/auth", authController); @@ -41,5 +42,6 @@ router.use("/oemmng", oemMngController); router.use("/productgroup", productgroupController); router.use("/product", productController); router.use("/carmng", carMngController); +router.use("/contract", contractController); module.exports = router; diff --git a/plm-api/src/services/contract.service.js b/plm-api/src/services/contract.service.js index a5f5822..81f5624 100644 --- a/plm-api/src/services/contract.service.js +++ b/plm-api/src/services/contract.service.js @@ -1,5 +1,5 @@ // src/services/contract.service.js -const { Contract, ContractDetail, Company } = require("../models"); +const { Contract, ContractDetail, Company, ContractMgmt} = require("../models"); const alertService = require("./alert.service"); const { Op } = require("sequelize"); // const { getKoreanSubjectParticle } = require("../utils/koreanParticle"); @@ -114,6 +114,118 @@ class ContractService { }, }; } + + // 영업정보 목록 조회 수정 + async getContractList(page = 1, limit = 20) { + try { + const offset = (page - 1) * limit; + + const { count, rows } = await ContractMgmt.findAndCountAll({ + attributes: { + exclude: ['createdAt', 'updatedAt', 'created_at', 'updated_at'] // 모든 타임스탬프 필드 제외 + }, + order: [['created_at', 'DESC']], // created_at으로 수정 + limit: limit, + offset: offset, + raw: true + }); + + if (!rows || !Array.isArray(rows)) { + throw new Error('No data found'); + } + + // 데이터 형식 변환 + const formatted = rows.map(row => ({ + ...row, + // Date 형식 변환 및 검사 추가 + customer_info: row.customer_info ? new Date(row.customer_info).toISOString() : null, + duckil_info: row.duckil_info ? new Date(row.duckil_info).toISOString() : null, + register_date: row.register_date ? new Date(row.register_date).toISOString() : null, + request_date: row.request_date ? new Date(row.request_date).toISOString() : null, + submit_date: row.submit_date ? new Date(row.submit_date).toISOString() : null, + created_at: row.created_at ? new Date(row.created_at).toISOString() : null, + updated_at: row.updated_at ? new Date(row.updated_at).toISOString() : null, + + // 숫자 데이터 변환 + progress: row.progress ? parseFloat(row.progress) : null, + customer_total: row.customer_total ? parseInt(row.customer_total, 10) : null, + duckil_production: row.duckil_production ? parseInt(row.duckil_production, 10) : null, + first_quarter: row.first_quarter ? parseInt(row.first_quarter, 10) : null, + second_quarter: row.second_quarter ? parseInt(row.second_quarter, 10) : null, + third_quarter: row.third_quarter ? parseInt(row.third_quarter, 10) : null, + fourth_quarter: row.fourth_quarter ? parseInt(row.fourth_quarter, 10) : null, + })); + + return { + oemMngs: formatted, + total: count, + totalPages: Math.ceil(count / limit), + currentPage: page, + pageSize: limit + }; + } catch (error) { + console.error('Contract Service Error:', error); // 에러 로깅 추가 + throw error; + } + } + + // 영업정보 생성 (데이터 타입 변환 추가) + async createContractMgmt(contractData) { + try { + const formattedData = this._formatContractData(contractData); + return await ContractMgmt.create(formattedData); + } catch (error) { + throw error; + } + } + + // 영업정보 수정 (데이터 타입 변환 추가) + async updateContractMgmt(id, updateData) { + try { + const contract = await ContractMgmt.findByPk(id); + if (!contract) throw new Error('Contract not found'); + + const formattedData = this._formatContractData(updateData); + await contract.update(formattedData); + return contract; + } catch (error) { + throw error; + } + } + + // 영업정보 삭제 + async deleteContractMgmt(id) { + try { + const contract = await ContractMgmt.findByPk(id); + if (!contract) throw new Error('Contract not found'); + + await contract.destroy(); + return true; + } catch (error) { + throw error; + } + } + + // 데이터 형식 변환 헬퍼 메서드 + _formatContractData(data) { + return { + ...data, + // 날짜 문자열을 Date 객체로 변환 + customer_info: data.customer_info ? new Date(data.customer_info) : null, + duckil_info: data.duckil_info ? new Date(data.duckil_info) : null, + register_date: data.register_date ? new Date(data.register_date) : null, + request_date: data.request_date ? new Date(data.request_date) : null, + submit_date: data.submit_date ? new Date(data.submit_date) : null, + // 숫자 문자열을 숫자로 변환 + progress: data.progress ? Number(data.progress) : null, + customer_total: data.customer_total ? Number(data.customer_total) : null, + duckil_production: data.duckil_production ? Number(data.duckil_production) : null, + first_quarter: data.first_quarter ? Number(data.first_quarter) : null, + second_quarter: data.second_quarter ? Number(data.second_quarter) : null, + third_quarter: data.third_quarter ? Number(data.third_quarter) : null, + fourth_quarter: data.fourth_quarter ? Number(data.fourth_quarter) : null, + }; + } } module.exports = new ContractService(); diff --git a/plm-app/src/app/(admin)/common/codeCategoryMngList/page.tsx b/plm-app/src/app/(admin)/common/codeCategoryMngList/page.tsx index b463702..50c875d 100644 --- a/plm-app/src/app/(admin)/common/codeCategoryMngList/page.tsx +++ b/plm-app/src/app/(admin)/common/codeCategoryMngList/page.tsx @@ -422,29 +422,6 @@ const CommonCodePage = () => { <div>{new Date(row.original.createdAt).toLocaleDateString()}</div> ), }, - { - id: "isActive", - accessorKey: "isActive", - header: "활성화", - meta: { - width: "200px", - textAlign: "center" - }, - cell: ({ row }) => ( - <div className="text-center"> - <Switch - checked={row.original.isActive} - onCheckedChange={(checked) => - toggleActiveMutation.mutate({ - id: row.original.id, - isActive: checked - }) - } - onClick={(e) => e.stopPropagation()} - /> - </div> - ), - }, { id: "addSub", header: "하위요소", @@ -468,6 +445,29 @@ const CommonCodePage = () => { </div> ), }, + { + id: "isActive", + accessorKey: "isActive", + header: "활성화", + meta: { + width: "200px", + textAlign: "center" + }, + cell: ({ row }) => ( + <div className="text-center"> + <Switch + checked={row.original.isActive} + onCheckedChange={(checked) => + toggleActiveMutation.mutate({ + id: row.original.id, + isActive: checked + }) + } + onClick={(e) => e.stopPropagation()} + /> + </div> + ), + }, { id: "actions", header: "삭제", diff --git a/plm-app/src/app/(admin)/common/menu/page.tsx b/plm-app/src/app/(admin)/common/menu/page.tsx index 83fb641..f380dec 100644 --- a/plm-app/src/app/(admin)/common/menu/page.tsx +++ b/plm-app/src/app/(admin)/common/menu/page.tsx @@ -373,27 +373,6 @@ const MenusPage = () => { textAlign: "center" } }, - { - id: "menu_type", - accessorKey: "menu_type", - header: "타입", - meta: { - width: "200px", - textAlign: "center" - }, - cell: ({ row }) => ( - <div className="text-center"> - <span className={cn( - "inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset", - row.original.menu_type === "0" - ? "bg-blue-50 text-blue-700 ring-blue-700/10" - : "bg-green-50 text-green-700 ring-green-600/20" - )}> - {row.original.menu_type === "0" ? "관리자 메뉴" : "사용자 메뉴"} - </span> - </div> - ), - }, { id: "addSub", header: "하위요소", @@ -417,6 +396,27 @@ const MenusPage = () => { </div> ), }, + { + id: "menu_type", + accessorKey: "menu_type", + header: "타입", + meta: { + width: "200px", + textAlign: "center" + }, + cell: ({ row }) => ( + <div className="text-center"> + <span className={cn( + "inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset", + row.original.menu_type === "0" + ? "bg-blue-50 text-blue-700 ring-blue-700/10" + : "bg-green-50 text-green-700 ring-green-600/20" + )}> + {row.original.menu_type === "0" ? "관리자 메뉴" : "사용자 메뉴"} + </span> + </div> + ), + }, { id: "isActive", accessorKey: "isActive", diff --git a/plm-app/src/components/common/Table/index.tsx b/plm-app/src/components/common/Table/index.tsx index 70c2681..5e2add6 100644 --- a/plm-app/src/components/common/Table/index.tsx +++ b/plm-app/src/components/common/Table/index.tsx @@ -13,6 +13,8 @@ import { type OnChangeFn, type Row, type TableOptions, + type GroupingColumnDef, + type Cell, } from "@tanstack/react-table"; import { ChevronRight, ChevronDown } from "lucide-react"; @@ -44,10 +46,79 @@ interface PLMTableProps<T extends BaseRow> { onCheckedChange?: (checkedRows: T[]) => void; onPageSizeChange?: (newPageSize: number) => void; // 새로운 prop 추가 isTreeTable?: boolean; // 트리 테이블 여부를 결정하는 prop 추가 + renderExpandedRow?: (row: Row<T>) => React.ReactNode; // 확장된 행 렌더링을 위한 prop + enableExpander?: boolean; // 확장 기능 활성화 여부 + treeConfig?: { // 트리 구조 설정 + getParentId?: (row: T) => string | null; + indent?: number; + }; + expanderConfig?: { // 행 확장 설정 + renderExpandedContent?: (row: Row<T>) => React.ReactNode; + expandedContentStyle?: React.CSSProperties; + }; + onCellEdit?: (rowId: string, field: string, value: any) => Promise<void>; + editableColumns?: string[]; // 수정 가능한 컬럼 목록 } type CustomExpandedState = Record<string | number, boolean>; +// EditableCell 컴포넌트 추가 +const EditableCell = <T extends BaseRow>({ + cell, + isEditable, + onCellEdit, +}: { + cell: Cell<T, unknown>; + isEditable?: boolean; + onCellEdit?: (rowId: string, field: string, value: any) => Promise<void>; +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState<string>(''); + const inputRef = useRef<HTMLInputElement>(null); + + const handleDoubleClick = () => { + if (!isEditable) return; + setIsEditing(true); + setEditValue(String(cell.getValue() || '')); + }; + + const handleSave = async () => { + if (onCellEdit) { + try { + await onCellEdit(cell.row.original.id, String(cell.column.id), editValue); + setIsEditing(false); + } catch (error) { + console.error('Failed to update cell:', error); + } + } + }; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + if (isEditing) { + return ( + <input + ref={inputRef} + className="w-full px-2 py-1 border rounded" + value={editValue} + onChange={(e) => setEditValue(e.target.value)} + onBlur={handleSave} + onKeyPress={(e) => e.key === 'Enter' && handleSave()} + /> + ); + } + + return ( + <div onDoubleClick={handleDoubleClick}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </div> + ); +}; + export function PLMTable<T extends BaseRow>({ columns: userColumns, data, @@ -57,6 +128,12 @@ export function PLMTable<T extends BaseRow>({ onCheckedChange, onPageSizeChange, isTreeTable = false, // 기본값은 false + renderExpandedRow, + enableExpander = false, + treeConfig, + expanderConfig, + onCellEdit, + editableColumns, }: PLMTableProps<T>) { const [expanded, setExpanded] = useState<CustomExpandedState>({}); const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({}); @@ -218,7 +295,7 @@ export function PLMTable<T extends BaseRow>({ <Button variant="ghost" size="icon" - className="h-8 w-8 p-0" + className={`h-8 w-8 p-0 ${row.depth > 0 ? 'ml-4' : ''}`} onClick={(e) => { e.stopPropagation(); row.toggleExpanded(); @@ -236,11 +313,39 @@ export function PLMTable<T extends BaseRow>({ }); } + // 확장/축소 버튼 컬럼 - enableExpander가 true일 때만 추가 + if (enableExpander) { + result.push({ + id: 'expander', + meta: { width: "40px" }, + header: () => null, + cell: ({ row }) => ( + <div className="flex justify-center"> + <Button + variant="ghost" + size="icon" + className="h-8 w-8 p-0" + onClick={(e) => { + e.stopPropagation(); + row.toggleExpanded(); + }} + > + {row.getIsExpanded() ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </Button> + </div> + ), + }); + } + // 나머지 컬럼들 추가 result.push(...userColumns); return result; - }, [userColumns, enableCheckbox, onCheckedChange, isTreeTable]); + }, [userColumns, enableCheckbox, onCheckedChange, isTreeTable, enableExpander]); const handleExpandedChange: OnChangeFn<ExpandedState> = useCallback((updater) => { setExpanded(old => { @@ -307,77 +412,245 @@ export function PLMTable<T extends BaseRow>({ onPageSizeChange(newPageSize); } }; + const getHeaderDepth = (columns: GroupingColumnDef<T, any>[]): number => { + let maxDepth = 1; + for (const column of columns) { + if ('columns' in column && Array.isArray(column.columns)) { + const subDepth = getHeaderDepth(column.columns as GroupingColumnDef<T, any>[]) + 1; + maxDepth = Math.max(maxDepth, subDepth); + } + } + return maxDepth; + }; + + const defaultRenderExpandedRow = (row: Row<T>) => { + const item = row.original; + return ( + <div className="pl-12 pr-4 py-2 bg-gray-50"> + <table className="min-w-full border-separate border-spacing-0 text-xs"> + {Object.entries(item).map(([key, value]) => { + // id나 기본 표시되는 컬럼들은 제외 + if (key === 'id' || columns.some(col => 'accessorKey' in col && col.accessorKey === key)) { + return null; + } + return ( + <tr key={key}> + <th className="w-48 py-1 px-2 text-left font-medium text-gray-700">{key}</th> + <td className="py-1 px-2">{String(value)}</td> + </tr> + ); + })} + </table> + </div> + ); + }; + + // 트리 구조 관련 로직 + const renderTreeCell = useCallback((row: Row<T>) => { + if (!isTreeTable || !treeConfig) return null; + + return ( + <div + className="relative" + style={{ paddingLeft: `${(row.depth || 0) * (treeConfig.indent || 24)}px` }} + > + {/* 트리 라인 렌더링 */} + </div> + ); + }, [isTreeTable, treeConfig]); + + // 행 확장 관련 로직 + const renderExpandedRowContent = useCallback((row: Row<T>) => { + if (!enableExpander || !expanderConfig?.renderExpandedContent) return null; + + return ( + <div style={expanderConfig.expandedContentStyle}> + {expanderConfig.renderExpandedContent(row)} + </div> + ); + }, [enableExpander, expanderConfig]); + + // renderCell 함수를 컴포넌트로 교체 + const renderCell = useCallback((cell: Cell<T, unknown>) => { + const isEditable = editableColumns?.includes(String(cell.column.id)); + return ( + <EditableCell + cell={cell} + isEditable={isEditable} + onCellEdit={onCellEdit} + /> + ); + }, [editableColumns, onCellEdit]); return ( <div> <div - className="relative overflow-y-auto overflow-x-auto bg-white border border-gray-200 rounded-lg shadow-sm" + className="relative border border-gray-200 rounded-lg shadow-sm" style={{ height: "600px" }} > - <table className="w-full border-separate border-spacing-0 text-sm leading-normal text-gray-700 table-fixed"> - <thead> - {table.getHeaderGroups().map((headerGroup) => ( - <tr key={headerGroup.id}> - {headerGroup.headers.map((header) => ( - <th - key={header.id} - className="sticky top-0 z-10 bg-gray-50 text-center py-3 px-4 font-semibold text-gray-900 border-b border-gray-200 whitespace-nowrap overflow-hidden text-ellipsis" - style={{ width: header.column.columnDef.meta?.width }} - > - {flexRender( - header.column.columnDef.header, - header.getContext() - )} - </th> - ))} - </tr> - ))} - </thead> - <tbody> - {table.getRowModel().rows.map((row) => ( - <tr - key={row.id} - className={`transition-colors hover:bg-gray-50`} - > - {row.getVisibleCells().map((cell) => { - const isFirstCell = cell.column.id === 'expander'; - return ( - <td - key={cell.id} - className={`py-3 px-4 border-b border-gray-200 overflow-visible - ${cell.column.columnDef.meta?.className || ""} - ${isFirstCell ? 'relative' : ''}`} - style={{ - width: cell.column.columnDef.meta?.width, - maxWidth: cell.column.columnDef.meta?.width, - textAlign: cell.column.columnDef.meta?.textAlign || 'left', - paddingLeft: isFirstCell ? `${row.depth * 24 + 16}px` : undefined, - }} - > - {isFirstCell && Array.from({ length: row.depth }).map((_, index) => ( - <span - key={`line-${index}`} - className="absolute h-full w-px bg-gray-200" - style={{ - left: `${(index + 1) * 24}px`, - top: 0, - zIndex: 1 + <div className="absolute top-0 left-0 right-0 bottom-0 overflow-auto"> + <table className="min-w-full border-separate border-spacing-0 text-[13px]"> {/* text-xs에서 text-[13px]로 변경 */} + <thead className="sticky top-0 z-10 bg-gray-100"> + {table.getHeaderGroups().map((headerGroup, groupIndex) => { + // 헤더 그룹의 전체 개수 + const totalHeaderGroups = table.getHeaderGroups().length; + + return ( + <tr key={headerGroup.id} className="bg-gray-50"> + {headerGroup.headers.map((header) => { + const isUtilityColumn = header.column.id === 'select' || header.column.id === 'expander'; + const isNestedHeader = header.column.columns?.length > 0; + + // 유틸리티 컬럼(체크박스, expander)은 첫 번째 행에서만 표시하고 전체 높이로 설정 + if (groupIndex > 0 && isUtilityColumn) { + return null; + } + + // 부모 컬럼이 없는 일반 컬럼은 첫 번째 행에서만 표시하고 전체 높이로 설정 + if (groupIndex > 0 && !header.column.parent && !isNestedHeader) { + return null; + } + + // 중첩된 컬럼의 자식은 마지막 행에서만 표시 + if (groupIndex < totalHeaderGroups - 1 && !isNestedHeader && header.column.parent) { + return null; + } + + // rowSpan 계산 + let rowSpan = 1; + if (isUtilityColumn || (!isNestedHeader && !header.column.parent)) { + rowSpan = totalHeaderGroups; // 전체 높이 + } else if (!isNestedHeader && header.column.parent) { + rowSpan = 1; // 자식 컬럼은 한 줄 + } else if (isNestedHeader) { + rowSpan = 1; // 그룹 헤더는 한 줄 + } + + return ( + <th + key={header.id} + colSpan={header.colSpan} + rowSpan={rowSpan} + className={` + sticky top-0 z-10 + text-center py-0.5 px-2 + font-semibold text-gray-900 + whitespace-nowrap overflow-hidden text-ellipsis + bg-gray-100 + ${isNestedHeader ? 'font-bold' : ''} + last:border-r-0 + `} + style={{ + width: header.column.columnDef.meta?.width, + minWidth: header.column.columnDef.meta?.width, + height: '24px', // 더 작게 조정 + borderBottom: '1px solid #D1D5DB', + borderRight: '1px solid #D1D5DB', + borderTop: groupIndex === 0 ? '1px solid #D1D5DB' : 'none', + borderLeft: 'none', }} - /> - ))} - <div className={isFirstCell ? 'relative z-10' : ''}> - {flexRender(cell.column.columnDef.cell, cell.getContext())} - </div> - </td> - ); - })} - </tr> - ))} - </tbody> - </table> + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + </th> + ); + })} + </tr> + ); + })} + </thead> + <tbody className="divide-y divide-gray-50"> + {table.getRowModel().rows.map((row) => ( + <React.Fragment key={row.id}> + <tr + className="transition-colors hover:bg-gray-50" + > + {row.getVisibleCells().map((cell, cellIndex) => { + const isFirstCell = cell.column.id === 'expander'; + const isSecondCell = cellIndex === 1; // expander 다음 셀 확인 + const hasExpandButton = row.getCanExpand(); + + return ( + <td + key={cell.id} + className={`py-0.5 px-3 border-b border-gray-200 + ${cell.column.columnDef.meta?.className || ""} + ${isFirstCell ? 'relative' : ''} + ${isSecondCell && hasExpandButton ? 'border-l-0' : ''} + ${!isFirstCell ? 'border-r border-gray-200' : ''}`} + style={{ + width: cell.column.columnDef.meta?.width, + maxWidth: cell.column.columnDef.meta?.width, + textAlign: cell.column.columnDef.meta?.textAlign || 'left', + paddingLeft: isFirstCell ? `${row.depth * 24 + 12}px` : undefined, + }} + > + {isFirstCell && row.depth > 0 && ( + <> + {/* 수직 라인 */} + <span + className="absolute h-full w-px bg-gray-200" + style={{ + left: '24px', + top: 0, + }} + /> + {/* 수평 라인 */} + <span + className="absolute w-4 h-px bg-gray-200" + style={{ + left: '24px', + top: '50%', + }} + /> + </> + )} + <div className={`relative ${isFirstCell ? 'z-10' : ''}`}> + {renderCell(cell)} + </div> + </td> + ); + })} + </tr> + {/* 확장된 행 렌더링 */} + {row.getIsExpanded() && ( + <tr> + <td + colSpan={row.getVisibleCells().length} + className="border-b border-gray-200 bg-transparent" + > + <div className="relative"> + {/* 왼쪽 세로 선 */} + <div + className="absolute left-0 top-0 bottom-0 w-[1px] bg-gray-200" + style={{ + left: `${row.depth * 24 + 24}px`, + }} + /> + {/* 확장된 내용을 선보다 오른쪽으로 이동 */} + <div + className="ml-[48px] py-2" + style={{ + width: 'calc(100% - 64px)', // 전체 너비에서 왼쪽 여백(48px)과 오른쪽 여백(16px)을 뺌 + marginRight: '16px' + }} + > + {renderExpandedRowContent(row)} + </div> + </div> + </td> + </tr> + )} + </React.Fragment> + ))} + </tbody> + </table> + </div> </div> - <div className="flex items-center justify-between py-3 text-sm text-gray-700"> + <div className="flex items-center justify-between py-3 text-[13px] text-gray-700"> {/* text-sm에서 text-[13px]로 변경 */} <div className="w-1/3 flex items-center gap-2"> <span>페이지당 행:</span> <select