diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index b75e6685..131b9e1a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -64,6 +64,8 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 +import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리 +import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -222,6 +224,8 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 +app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리 +app.use("/api/departments", departmentRoutes); // 부서 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index b1638403..f79aec69 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -8,6 +8,7 @@ import config from "../config/environment"; import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; import { FileSystemManager } from "../utils/fileSystemManager"; +import { validateBusinessNumber } from "../utils/businessNumberValidator"; /** * 관리자 메뉴 목록 조회 @@ -609,9 +610,15 @@ export const getCompanyList = async ( // Raw Query로 회사 목록 조회 const companies = await query( - `SELECT - company_code, + ` SELECT + company_code, company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, status, writer, regdate @@ -1659,9 +1666,15 @@ export async function getCompanyListFromDB( // Raw Query로 회사 목록 조회 const companies = await query( - `SELECT - company_code, + ` SELECT + company_code, company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, writer, regdate, status @@ -2440,6 +2453,25 @@ export const createCompany = async ( [company_name.trim()] ); + // 사업자등록번호 유효성 검증 + const businessNumberValidation = validateBusinessNumber( + req.body.business_registration_number?.trim() || "" + ); + if (!businessNumberValidation.isValid) { + res.status(400).json({ + success: false, + message: businessNumberValidation.message, + errorCode: "INVALID_BUSINESS_NUMBER", + }); + return; + } + + // Raw Query로 사업자등록번호 중복 체크 + const existingBusinessNumber = await queryOne( + `SELECT company_code FROM company_mng WHERE business_registration_number = $1`, + [req.body.business_registration_number?.trim()] + ); + if (existingCompany) { res.status(400).json({ success: false, @@ -2449,6 +2481,15 @@ export const createCompany = async ( return; } + if (existingBusinessNumber) { + res.status(400).json({ + success: false, + message: "이미 등록된 사업자등록번호입니다.", + errorCode: "DUPLICATE_BUSINESS_NUMBER", + }); + return; + } + // PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용) const client = new Client({ connectionString: @@ -2474,11 +2515,17 @@ export const createCompany = async ( const insertQuery = ` INSERT INTO company_mng ( company_code, - company_name, + company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, writer, regdate, status - ) VALUES ($1, $2, $3, $4, $5) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING * `; @@ -2488,6 +2535,12 @@ export const createCompany = async ( const insertValues = [ companyCode, company_name.trim(), + req.body.business_registration_number?.trim() || null, + req.body.representative_name?.trim() || null, + req.body.representative_phone?.trim() || null, + req.body.email?.trim() || null, + req.body.website?.trim() || null, + req.body.address?.trim() || null, writer, new Date(), "active", @@ -2552,7 +2605,16 @@ export const updateCompany = async ( ): Promise => { try { const { companyCode } = req.params; - const { company_name, status } = req.body; + const { + company_name, + business_registration_number, + representative_name, + representative_phone, + email, + website, + address, + status, + } = req.body; logger.info("회사 정보 수정 요청", { companyCode, @@ -2586,13 +2648,61 @@ export const updateCompany = async ( return; } + // 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외) + if (business_registration_number && business_registration_number.trim()) { + // 유효성 검증 + const businessNumberValidation = validateBusinessNumber(business_registration_number.trim()); + if (!businessNumberValidation.isValid) { + res.status(400).json({ + success: false, + message: businessNumberValidation.message, + errorCode: "INVALID_BUSINESS_NUMBER", + }); + return; + } + + // 중복 체크 + const duplicateBusinessNumber = await queryOne( + `SELECT company_code FROM company_mng + WHERE business_registration_number = $1 AND company_code != $2`, + [business_registration_number.trim(), companyCode] + ); + + if (duplicateBusinessNumber) { + res.status(400).json({ + success: false, + message: "이미 등록된 사업자등록번호입니다.", + errorCode: "DUPLICATE_BUSINESS_NUMBER", + }); + return; + } + } + // Raw Query로 회사 정보 수정 const result = await query( `UPDATE company_mng - SET company_name = $1, status = $2 - WHERE company_code = $3 + SET + company_name = $1, + business_registration_number = $2, + representative_name = $3, + representative_phone = $4, + email = $5, + website = $6, + address = $7, + status = $8 + WHERE company_code = $9 RETURNING *`, - [company_name.trim(), status || "active", companyCode] + [ + company_name.trim(), + business_registration_number?.trim() || null, + representative_name?.trim() || null, + representative_phone?.trim() || null, + email?.trim() || null, + website?.trim() || null, + address?.trim() || null, + status || "active", + companyCode, + ] ); if (result.length === 0) { diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts new file mode 100644 index 00000000..9e3f0b6a --- /dev/null +++ b/backend-node/src/controllers/departmentController.ts @@ -0,0 +1,534 @@ +import { Response } from "express"; +import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; +import { ApiResponse } from "../types/common"; +import { query, queryOne } from "../database/db"; + +/** + * 부서 목록 조회 (회사별) + */ +export async function getDepartments(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const userCompanyCode = req.user?.companyCode; + + logger.info("부서 목록 조회", { companyCode, userCompanyCode }); + + // 최고 관리자가 아니면 자신의 회사만 조회 가능 + if (userCompanyCode !== "*" && userCompanyCode !== companyCode) { + res.status(403).json({ + success: false, + message: "해당 회사의 부서를 조회할 권한이 없습니다.", + }); + return; + } + + // 부서 목록 조회 (부서원 수 포함) + const departments = await query(` + SELECT + d.dept_code, + d.dept_name, + d.company_code, + d.parent_dept_code, + COUNT(DISTINCT ud.user_id) as member_count + FROM dept_info d + LEFT JOIN user_dept ud ON d.dept_code = ud.dept_code + WHERE d.company_code = $1 + GROUP BY d.dept_code, d.dept_name, d.company_code, d.parent_dept_code + ORDER BY d.dept_name + `, [companyCode]); + + // 응답 형식 변환 + const formattedDepartments = departments.map((dept) => ({ + dept_code: dept.dept_code, + dept_name: dept.dept_name, + company_code: dept.company_code, + parent_dept_code: dept.parent_dept_code, + memberCount: parseInt(dept.member_count || "0"), + })); + + res.status(200).json({ + success: true, + data: formattedDepartments, + }); + } catch (error) { + logger.error("부서 목록 조회 실패", error); + res.status(500).json({ + success: false, + message: "부서 목록 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 상세 조회 + */ +export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + + const department = await queryOne(` + SELECT + dept_code, + dept_name, + company_code, + parent_dept_code + FROM dept_info + WHERE dept_code = $1 + `, [deptCode]); + + if (!department) { + res.status(404).json({ + success: false, + message: "부서를 찾을 수 없습니다.", + }); + return; + } + + res.status(200).json({ + success: true, + data: department, + }); + } catch (error) { + logger.error("부서 상세 조회 실패", error); + res.status(500).json({ + success: false, + message: "부서 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 생성 + */ +export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const { dept_name, parent_dept_code } = req.body; + + if (!dept_name || !dept_name.trim()) { + res.status(400).json({ + success: false, + message: "부서명을 입력해주세요.", + }); + return; + } + + // 같은 회사 내 중복 부서명 확인 + const duplicate = await queryOne(` + SELECT dept_code, dept_name + FROM dept_info + WHERE company_code = $1 AND dept_name = $2 + `, [companyCode, dept_name.trim()]); + + if (duplicate) { + res.status(409).json({ + success: false, + message: `"${dept_name}" 부서가 이미 존재합니다.`, + isDuplicate: true, + }); + return; + } + + // 회사 이름 조회 + const company = await queryOne(` + SELECT company_name FROM company_mng WHERE company_code = $1 + `, [companyCode]); + + const companyName = company?.company_name || companyCode; + + // 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...) + const codeResult = await queryOne(` + SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number + FROM dept_info + WHERE dept_code ~ '^DEPT_[0-9]+$' + `); + + const nextNumber = codeResult?.next_number || 1; + const deptCode = `DEPT_${nextNumber}`; + + // 부서 생성 + const result = await query(` + INSERT INTO dept_info ( + dept_code, + dept_name, + company_code, + company_name, + parent_dept_code, + status, + regdate + ) VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING * + `, [ + deptCode, + dept_name.trim(), + companyCode, + companyName, + parent_dept_code || null, + 'active', + ]); + + logger.info("부서 생성 성공", { deptCode, dept_name }); + + res.status(201).json({ + success: true, + message: "부서가 생성되었습니다.", + data: result[0], + }); + } catch (error) { + logger.error("부서 생성 실패", error); + res.status(500).json({ + success: false, + message: "부서 생성 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 수정 + */ +export async function updateDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + const { dept_name, parent_dept_code } = req.body; + + if (!dept_name || !dept_name.trim()) { + res.status(400).json({ + success: false, + message: "부서명을 입력해주세요.", + }); + return; + } + + const result = await query(` + UPDATE dept_info + SET + dept_name = $1, + parent_dept_code = $2 + WHERE dept_code = $3 + RETURNING * + `, [dept_name.trim(), parent_dept_code || null, deptCode]); + + if (result.length === 0) { + res.status(404).json({ + success: false, + message: "부서를 찾을 수 없습니다.", + }); + return; + } + + logger.info("부서 수정 성공", { deptCode }); + + res.status(200).json({ + success: true, + message: "부서가 수정되었습니다.", + data: result[0], + }); + } catch (error) { + logger.error("부서 수정 실패", error); + res.status(500).json({ + success: false, + message: "부서 수정 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서 삭제 + */ +export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + + // 하위 부서 확인 + const hasChildren = await queryOne(` + SELECT COUNT(*) as count + FROM dept_info + WHERE parent_dept_code = $1 + `, [deptCode]); + + if (parseInt(hasChildren?.count || "0") > 0) { + res.status(400).json({ + success: false, + message: "하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.", + }); + return; + } + + // 부서원 삭제 (부서 삭제 전에 먼저 삭제) + const deletedMembers = await query(` + DELETE FROM user_dept + WHERE dept_code = $1 + RETURNING user_id + `, [deptCode]); + + const memberCount = deletedMembers.length; + + // 부서 삭제 + const result = await query(` + DELETE FROM dept_info + WHERE dept_code = $1 + RETURNING dept_code, dept_name + `, [deptCode]); + + if (result.length === 0) { + res.status(404).json({ + success: false, + message: "부서를 찾을 수 없습니다.", + }); + return; + } + + logger.info("부서 삭제 성공", { + deptCode, + deptName: result[0].dept_name, + deletedMemberCount: memberCount + }); + + res.status(200).json({ + success: true, + message: memberCount > 0 + ? `부서가 삭제되었습니다. (부서원 ${memberCount}명 제외됨)` + : "부서가 삭제되었습니다.", + }); + } catch (error) { + logger.error("부서 삭제 실패", error); + res.status(500).json({ + success: false, + message: "부서 삭제 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서원 목록 조회 + */ +export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + + const members = await query(` + SELECT + u.user_id, + u.user_name, + u.email, + u.tel as phone, + u.cell_phone, + u.position_name, + ud.dept_code, + d.dept_name, + ud.is_primary + FROM user_dept ud + JOIN user_info u ON ud.user_id = u.user_id + JOIN dept_info d ON ud.dept_code = d.dept_code + WHERE ud.dept_code = $1 + ORDER BY ud.is_primary DESC, u.user_name + `, [deptCode]); + + res.status(200).json({ + success: true, + data: members, + }); + } catch (error) { + logger.error("부서원 목록 조회 실패", error); + res.status(500).json({ + success: false, + message: "부서원 목록 조회 중 오류가 발생했습니다.", + }); + } +} + +/** + * 사용자 검색 (부서원 추가용) + */ +export async function searchUsers(req: AuthenticatedRequest, res: Response): Promise { + try { + const { companyCode } = req.params; + const { search } = req.query; + + if (!search || typeof search !== 'string') { + res.status(400).json({ + success: false, + message: "검색어를 입력해주세요.", + }); + return; + } + + // 사용자 검색 (ID 또는 이름) + const users = await query(` + SELECT + user_id, + user_name, + email, + position_name, + company_code + FROM user_info + WHERE company_code = $1 + AND ( + user_id ILIKE $2 OR + user_name ILIKE $2 + ) + ORDER BY user_name + LIMIT 20 + `, [companyCode, `%${search}%`]); + + res.status(200).json({ + success: true, + data: users, + }); + } catch (error) { + logger.error("사용자 검색 실패", error); + res.status(500).json({ + success: false, + message: "사용자 검색 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서원 추가 + */ +export async function addDepartmentMember(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode } = req.params; + const { user_id } = req.body; + + if (!user_id) { + res.status(400).json({ + success: false, + message: "사용자 ID를 입력해주세요.", + }); + return; + } + + // 사용자 존재 확인 + const user = await queryOne(` + SELECT user_id, user_name + FROM user_info + WHERE user_id = $1 + `, [user_id]); + + if (!user) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + // 이미 부서원인지 확인 + const existing = await queryOne(` + SELECT * + FROM user_dept + WHERE user_id = $1 AND dept_code = $2 + `, [user_id, deptCode]); + + if (existing) { + res.status(409).json({ + success: false, + message: "이미 해당 부서의 부서원입니다.", + isDuplicate: true, + }); + return; + } + + // 주 부서가 있는지 확인 + const hasPrimary = await queryOne(` + SELECT * + FROM user_dept + WHERE user_id = $1 AND is_primary = true + `, [user_id]); + + // 부서원 추가 + await query(` + INSERT INTO user_dept (user_id, dept_code, is_primary, created_at) + VALUES ($1, $2, $3, NOW()) + `, [user_id, deptCode, !hasPrimary]); + + logger.info("부서원 추가 성공", { user_id, deptCode }); + + res.status(201).json({ + success: true, + message: "부서원이 추가되었습니다.", + }); + } catch (error) { + logger.error("부서원 추가 실패", error); + res.status(500).json({ + success: false, + message: "부서원 추가 중 오류가 발생했습니다.", + }); + } +} + +/** + * 부서원 제거 + */ +export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode, userId } = req.params; + + const result = await query(` + DELETE FROM user_dept + WHERE user_id = $1 AND dept_code = $2 + RETURNING * + `, [userId, deptCode]); + + if (result.length === 0) { + res.status(404).json({ + success: false, + message: "해당 부서원을 찾을 수 없습니다.", + }); + return; + } + + logger.info("부서원 제거 성공", { userId, deptCode }); + + res.status(200).json({ + success: true, + message: "부서원이 제거되었습니다.", + }); + } catch (error) { + logger.error("부서원 제거 실패", error); + res.status(500).json({ + success: false, + message: "부서원 제거 중 오류가 발생했습니다.", + }); + } +} + +/** + * 주 부서 설정 + */ +export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise { + try { + const { deptCode, userId } = req.params; + + // 다른 부서의 주 부서 해제 + await query(` + UPDATE user_dept + SET is_primary = false + WHERE user_id = $1 + `, [userId]); + + // 해당 부서를 주 부서로 설정 + await query(` + UPDATE user_dept + SET is_primary = true + WHERE user_id = $1 AND dept_code = $2 + `, [userId, deptCode]); + + logger.info("주 부서 설정 성공", { userId, deptCode }); + + res.status(200).json({ + success: true, + message: "주 부서가 설정되었습니다.", + }); + } catch (error) { + logger.error("주 부서 설정 실패", error); + res.status(500).json({ + success: false, + message: "주 부서 설정 중 오류가 발생했습니다.", + }); + } +} + diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 47ee4e94..9b8ef6fc 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -12,6 +12,14 @@ export const saveFormData = async ( const { companyCode, userId } = req.user as any; const { screenId, tableName, data } = req.body; + // 🔍 디버깅: 사용자 정보 확인 + console.log("🔍 [saveFormData] 사용자 정보:", { + userId, + companyCode, + reqUser: req.user, + dataWriter: data.writer, + }); + // 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크) if (screenId === undefined || screenId === null || !tableName || !data) { return res.status(400).json({ @@ -25,9 +33,12 @@ export const saveFormData = async ( ...data, created_by: userId, updated_by: userId, + writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정 screen_id: screenId, }; + console.log("✅ [saveFormData] 최종 writer 값:", formDataWithMeta.writer); + // company_code는 사용자가 명시적으로 입력한 경우에만 추가 if (data.company_code !== undefined) { formDataWithMeta.company_code = data.company_code; @@ -86,6 +97,7 @@ export const saveFormDataEnhanced = async ( ...data, created_by: userId, updated_by: userId, + writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정 screen_id: screenId, }; @@ -134,6 +146,7 @@ export const updateFormData = async ( const formDataWithMeta = { ...data, updated_by: userId, + writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정 updated_at: new Date(), }; @@ -186,6 +199,7 @@ export const updateFormDataPartial = async ( const newDataWithMeta = { ...newData, updated_by: userId, + writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정 }; const result = await dynamicFormService.updateFormDataPartial( diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts new file mode 100644 index 00000000..42b6172f --- /dev/null +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -0,0 +1,131 @@ +/** + * 채번 규칙 관리 컨트롤러 + */ + +import { Router, Request, Response } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { numberingRuleService } from "../services/numberingRuleService"; +import { logger } from "../utils/logger"; + +const router = Router(); + +// 규칙 목록 조회 +router.get("/", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + + try { + const rules = await numberingRuleService.getRuleList(companyCode); + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("규칙 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 특정 규칙 조회 +router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + const rule = await numberingRuleService.getRuleById(ruleId, companyCode); + if (!rule) { + return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" }); + } + return res.json({ success: true, data: rule }); + } catch (error: any) { + logger.error("규칙 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 규칙 생성 +router.post("/", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const ruleConfig = req.body; + + try { + if (!ruleConfig.ruleId || !ruleConfig.ruleName) { + return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" }); + } + + if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) { + return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" }); + } + + const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId); + return res.status(201).json({ success: true, data: newRule }); + } catch (error: any) { + if (error.code === "23505") { + return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" }); + } + logger.error("규칙 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 규칙 수정 +router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const updates = req.body; + + try { + const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode); + return res.json({ success: true, data: updatedRule }); + } catch (error: any) { + if (error.message.includes("찾을 수 없거나")) { + return res.status(404).json({ success: false, error: error.message }); + } + logger.error("규칙 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 규칙 삭제 +router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + await numberingRuleService.deleteRule(ruleId, companyCode); + return res.json({ success: true, message: "규칙이 삭제되었습니다" }); + } catch (error: any) { + if (error.message.includes("찾을 수 없거나")) { + return res.status(404).json({ success: false, error: error.message }); + } + logger.error("규칙 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 코드 생성 +router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode); + return res.json({ success: true, data: { code: generatedCode } }); + } catch (error: any) { + logger.error("코드 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 시퀀스 초기화 +router.post("/:ruleId/reset", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + await numberingRuleService.resetSequence(ruleId, companyCode); + return res.json({ success: true, message: "시퀀스가 초기화되었습니다" }); + } catch (error: any) { + logger.error("시퀀스 초기화 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +export default router; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index d7b2bd74..9661ab0a 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -12,6 +12,7 @@ import { ColumnListResponse, ColumnSettingsResponse, } from "../types/tableManagement"; +import { query } from "../database/db"; // 🆕 query 함수 import /** * 테이블 목록 조회 @@ -506,7 +507,91 @@ export async function updateColumnInputType( } /** - * 테이블 데이터 조회 (페이징 + 검색) + * 단일 레코드 조회 (자동 입력용) + */ +export async function getTableRecord( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { filterColumn, filterValue, displayColumn } = req.body; + + logger.info(`=== 단일 레코드 조회 시작: ${tableName} ===`); + logger.info(`필터: ${filterColumn} = ${filterValue}`); + logger.info(`표시 컬럼: ${displayColumn}`); + + if (!tableName || !filterColumn || !filterValue || !displayColumn) { + const response: ApiResponse = { + success: false, + message: "필수 파라미터가 누락되었습니다.", + error: { + code: "MISSING_PARAMETERS", + details: + "tableName, filterColumn, filterValue, displayColumn이 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + + // 단일 레코드 조회 (WHERE filterColumn = filterValue) + const result = await tableManagementService.getTableData(tableName, { + page: 1, + size: 1, + search: { + [filterColumn]: filterValue, + }, + }); + + if (!result.data || result.data.length === 0) { + const response: ApiResponse = { + success: false, + message: "데이터를 찾을 수 없습니다.", + error: { + code: "NOT_FOUND", + details: `${filterColumn} = ${filterValue}에 해당하는 데이터가 없습니다.`, + }, + }; + res.status(404).json(response); + return; + } + + const record = result.data[0]; + const displayValue = record[displayColumn]; + + logger.info(`레코드 조회 완료: ${displayColumn} = ${displayValue}`); + + const response: ApiResponse<{ value: any; record: any }> = { + success: true, + message: "레코드를 성공적으로 조회했습니다.", + data: { + value: displayValue, + record: record, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("레코드 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "레코드 조회 중 오류가 발생했습니다.", + error: { + code: "RECORD_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + +/** + * 테이블 데이터 조회 (페이징 + 검색 + 필터링) */ export async function getTableData( req: AuthenticatedRequest, @@ -520,12 +605,14 @@ export async function getTableData( search = {}, sortBy, sortOrder = "asc", + autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달) } = req.body; logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); logger.info(`페이징: page=${page}, size=${size}`); logger.info(`검색 조건:`, search); logger.info(`정렬: ${sortBy} ${sortOrder}`); + logger.info(`자동 필터:`, autoFilter); // 🆕 if (!tableName) { const response: ApiResponse = { @@ -542,11 +629,35 @@ export async function getTableData( const tableManagementService = new TableManagementService(); + // 🆕 현재 사용자 필터 적용 + let enhancedSearch = { ...search }; + if (autoFilter?.enabled && req.user) { + const filterColumn = autoFilter.filterColumn || "company_code"; + const userField = autoFilter.userField || "companyCode"; + const userValue = (req.user as any)[userField]; + + if (userValue) { + enhancedSearch[filterColumn] = userValue; + + logger.info("🔍 현재 사용자 필터 적용:", { + filterColumn, + userField, + userValue, + tableName, + }); + } else { + logger.warn("⚠️ 사용자 정보 필드 값 없음:", { + userField, + user: req.user, + }); + } + } + // 데이터 조회 const result = await tableManagementService.getTableData(tableName, { page: parseInt(page), size: parseInt(size), - search, + search: enhancedSearch, // 🆕 필터가 적용된 search 사용 sortBy, sortOrder, }); @@ -1216,9 +1327,7 @@ export async function getLogData( originalId: originalId as string, }); - logger.info( - `로그 데이터 조회 완료: ${tableName}_log, ${result.total}건` - ); + logger.info(`로그 데이터 조회 완료: ${tableName}_log, ${result.total}건`); const response: ApiResponse = { success: true, @@ -1254,7 +1363,9 @@ export async function toggleLogTable( const { tableName } = req.params; const { isActive } = req.body; - logger.info(`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`); + logger.info( + `=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===` + ); if (!tableName) { const response: ApiResponse = { @@ -1288,9 +1399,7 @@ export async function toggleLogTable( isActive === "Y" || isActive === true ); - logger.info( - `로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}` - ); + logger.info(`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`); const response: ApiResponse = { success: true, diff --git a/backend-node/src/routes/departmentRoutes.ts b/backend-node/src/routes/departmentRoutes.ts new file mode 100644 index 00000000..52cc309e --- /dev/null +++ b/backend-node/src/routes/departmentRoutes.ts @@ -0,0 +1,46 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as departmentController from "../controllers/departmentController"; + +const router = Router(); + +// 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * 부서 관리 API 라우트 + * 기본 경로: /api/departments + */ + +// 부서 목록 조회 (회사별) +router.get("/companies/:companyCode/departments", departmentController.getDepartments); + +// 부서 상세 조회 +router.get("/:deptCode", departmentController.getDepartment); + +// 부서 생성 +router.post("/companies/:companyCode/departments", departmentController.createDepartment); + +// 부서 수정 +router.put("/:deptCode", departmentController.updateDepartment); + +// 부서 삭제 +router.delete("/:deptCode", departmentController.deleteDepartment); + +// 부서원 목록 조회 +router.get("/:deptCode/members", departmentController.getDepartmentMembers); + +// 사용자 검색 (부서원 추가용) +router.get("/companies/:companyCode/users/search", departmentController.searchUsers); + +// 부서원 추가 +router.post("/:deptCode/members", departmentController.addDepartmentMember); + +// 부서원 제거 +router.delete("/:deptCode/members/:userId", departmentController.removeDepartmentMember); + +// 주 부서 설정 +router.put("/:deptCode/members/:userId/primary", departmentController.setPrimaryDepartment); + +export default router; + diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 5e5ddf38..9840c9c4 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -11,6 +11,7 @@ import { updateColumnInputType, updateTableLabel, getTableData, + getTableRecord, // 🆕 단일 레코드 조회 addTableData, editTableData, deleteTableData, @@ -134,6 +135,12 @@ router.get("/health", checkDatabaseConnection); */ router.post("/tables/:tableName/data", getTableData); +/** + * 단일 레코드 조회 (자동 입력용) + * POST /api/table-management/tables/:tableName/record + */ +router.post("/tables/:tableName/record", getTableRecord); + /** * 테이블 데이터 추가 * POST /api/table-management/tables/:tableName/add diff --git a/backend-node/src/services/ddlExecutionService.ts b/backend-node/src/services/ddlExecutionService.ts index 37659bcf..2ed01231 100644 --- a/backend-node/src/services/ddlExecutionService.ts +++ b/backend-node/src/services/ddlExecutionService.ts @@ -363,7 +363,7 @@ export class DDLExecutionService { "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, "created_date" timestamp DEFAULT now(), "updated_date" timestamp DEFAULT now(), - "writer" varchar(500), + "writer" varchar(500) DEFAULT NULL, "company_code" varchar(500)`; // 최종 CREATE TABLE 쿼리 diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts new file mode 100644 index 00000000..c61fce29 --- /dev/null +++ b/backend-node/src/services/numberingRuleService.ts @@ -0,0 +1,465 @@ +/** + * 채번 규칙 관리 서비스 + */ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +interface NumberingRulePart { + id?: number; + order: number; + partType: string; + generationMethod: string; + autoConfig?: any; + manualConfig?: any; + generatedValue?: string; +} + +interface NumberingRuleConfig { + ruleId: string; + ruleName: string; + description?: string; + parts: NumberingRulePart[]; + separator?: string; + resetPeriod?: string; + currentSequence?: number; + tableName?: string; + columnName?: string; + companyCode?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; +} + +class NumberingRuleService { + /** + * 규칙 목록 조회 + */ + async getRuleList(companyCode: string): Promise { + try { + logger.info("채번 규칙 목록 조회 시작", { companyCode }); + + const pool = getPool(); + const query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE company_code = $1 OR company_code = '*' + ORDER BY created_at DESC + `; + + const result = await pool.query(query, [companyCode]); + + // 각 규칙의 파트 정보 조회 + for (const rule of result.rows) { + const partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') + ORDER BY part_order + `; + + const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + rule.parts = partsResult.rows; + } + + logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { companyCode }); + return result.rows; + } catch (error: any) { + logger.error(`채번 규칙 목록 조회 중 에러: ${error.message}`); + throw error; + } + } + + /** + * 특정 규칙 조회 + */ + async getRuleById(ruleId: string, companyCode: string): Promise { + const pool = getPool(); + const query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_id AS "menuId", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') + `; + + const result = await pool.query(query, [ruleId, companyCode]); + if (result.rowCount === 0) return null; + + const rule = result.rows[0]; + + const partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') + ORDER BY part_order + `; + + const partsResult = await pool.query(partsQuery, [ruleId, companyCode]); + rule.parts = partsResult.rows; + + return rule; + } + + /** + * 규칙 생성 + */ + async createRule( + config: NumberingRuleConfig, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 마스터 삽입 + const insertRuleQuery = ` + INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + menu_objid, scope_type, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + `; + + const ruleResult = await client.query(insertRuleQuery, [ + config.ruleId, + config.ruleName, + config.description || null, + config.separator || "-", + config.resetPeriod || "none", + config.currentSequence || 1, + config.tableName || null, + config.columnName || null, + companyCode, + config.menuObjid || null, + config.scopeType || "global", + userId, + ]); + + // 파트 삽입 + const parts: NumberingRulePart[] = []; + for (const part of config.parts) { + const insertPartQuery = ` + INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + `; + + const partResult = await client.query(insertPartQuery, [ + config.ruleId, + part.order, + part.partType, + part.generationMethod, + JSON.stringify(part.autoConfig || {}), + JSON.stringify(part.manualConfig || {}), + companyCode, + ]); + + parts.push(partResult.rows[0]); + } + + await client.query("COMMIT"); + logger.info("채번 규칙 생성 완료", { ruleId: config.ruleId, companyCode }); + return { ...ruleResult.rows[0], parts }; + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("채번 규칙 생성 실패", { error: error.message }); + throw error; + } finally { + client.release(); + } + } + + /** + * 규칙 수정 + */ + async updateRule( + ruleId: string, + updates: Partial, + companyCode: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const updateRuleQuery = ` + UPDATE numbering_rules + SET + rule_name = COALESCE($1, rule_name), + description = COALESCE($2, description), + separator = COALESCE($3, separator), + reset_period = COALESCE($4, reset_period), + table_name = COALESCE($5, table_name), + column_name = COALESCE($6, column_name), + menu_objid = COALESCE($7, menu_objid), + scope_type = COALESCE($8, scope_type), + updated_at = NOW() + WHERE rule_id = $9 AND company_code = $10 + RETURNING + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + `; + + const ruleResult = await client.query(updateRuleQuery, [ + updates.ruleName, + updates.description, + updates.separator, + updates.resetPeriod, + updates.tableName, + updates.columnName, + updates.menuObjid, + updates.scopeType, + ruleId, + companyCode, + ]); + + if (ruleResult.rowCount === 0) { + throw new Error("규칙을 찾을 수 없거나 권한이 없습니다"); + } + + // 파트 업데이트 + let parts: NumberingRulePart[] = []; + if (updates.parts) { + await client.query( + "DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + + for (const part of updates.parts) { + const insertPartQuery = ` + INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + `; + + const partResult = await client.query(insertPartQuery, [ + ruleId, + part.order, + part.partType, + part.generationMethod, + JSON.stringify(part.autoConfig || {}), + JSON.stringify(part.manualConfig || {}), + companyCode, + ]); + + parts.push(partResult.rows[0]); + } + } + + await client.query("COMMIT"); + logger.info("채번 규칙 수정 완료", { ruleId, companyCode }); + return { ...ruleResult.rows[0], parts }; + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("채번 규칙 수정 실패", { error: error.message }); + throw error; + } finally { + client.release(); + } + } + + /** + * 규칙 삭제 + */ + async deleteRule(ruleId: string, companyCode: string): Promise { + const pool = getPool(); + const query = ` + DELETE FROM numbering_rules + WHERE rule_id = $1 AND company_code = $2 + `; + + const result = await pool.query(query, [ruleId, companyCode]); + + if (result.rowCount === 0) { + throw new Error("규칙을 찾을 수 없거나 권한이 없습니다"); + } + + logger.info("채번 규칙 삭제 완료", { ruleId, companyCode }); + } + + /** + * 코드 생성 + */ + async generateCode(ruleId: string, companyCode: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const rule = await this.getRuleById(ruleId, companyCode); + if (!rule) throw new Error("규칙을 찾을 수 없습니다"); + + const parts = rule.parts + .sort((a: any, b: any) => a.order - b.order) + .map((part: any) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || ""; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "prefix": + return autoConfig.prefix || "PREFIX"; + + case "sequence": { + const length = autoConfig.sequenceLength || 4; + return String(rule.currentSequence || 1).padStart(length, "0"); + } + + case "date": + return this.formatDate(new Date(), autoConfig.dateFormat || "YYYYMMDD"); + + case "year": { + const format = autoConfig.dateFormat || "YYYY"; + const year = new Date().getFullYear(); + return format === "YY" ? String(year).slice(-2) : String(year); + } + + case "month": + return String(new Date().getMonth() + 1).padStart(2, "0"); + + case "custom": + return autoConfig.value || "CUSTOM"; + + default: + return ""; + } + }); + + const generatedCode = parts.join(rule.separator || ""); + + const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); + if (hasSequence) { + await client.query( + "UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + } + + await client.query("COMMIT"); + logger.info("코드 생성 완료", { ruleId, generatedCode }); + return generatedCode; + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("코드 생성 실패", { error: error.message }); + throw error; + } finally { + client.release(); + } + } + + private formatDate(date: Date, format: string): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + switch (format) { + case "YYYY": return String(year); + case "YY": return String(year).slice(-2); + case "YYYYMM": return `${year}${month}`; + case "YYMM": return `${String(year).slice(-2)}${month}`; + case "YYYYMMDD": return `${year}${month}${day}`; + case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; + default: return `${year}${month}${day}`; + } + } + + async resetSequence(ruleId: string, companyCode: string): Promise { + const pool = getPool(); + await pool.query( + "UPDATE numbering_rules SET current_sequence = 1, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + logger.info("시퀀스 초기화 완료", { ruleId, companyCode }); + } +} + +export const numberingRuleService = new NumberingRuleService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 10de1e73..df67e2fe 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1502,6 +1502,9 @@ export class TableManagementService { LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; + logger.info(`🔍 실행할 SQL: ${dataQuery}`); + logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`); + let data = await query(dataQuery, [...searchValues, size, offset]); // 🎯 파일 컬럼이 있으면 파일 정보 보강 @@ -2980,20 +2983,20 @@ export class TableManagementService { try { logger.info(`컬럼 입력타입 정보 조회: ${tableName}`); - // table_type_columns에서 입력타입 정보 조회 + // column_labels에서 입력타입 정보 조회 const rawInputTypes = await query( `SELECT - ttc.column_name as "columnName", - ttc.column_name as "displayName", - COALESCE(ttc.input_type, 'text') as "inputType", - COALESCE(ttc.detail_settings, '{}') as "detailSettings", - ttc.is_nullable as "isNullable", + cl.column_name as "columnName", + cl.column_label as "displayName", + COALESCE(cl.input_type, 'text') as "inputType", + '{}'::jsonb as "detailSettings", + ic.is_nullable as "isNullable", ic.data_type as "dataType" - FROM table_type_columns ttc + FROM column_labels cl LEFT JOIN information_schema.columns ic - ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name - WHERE ttc.table_name = $1 - ORDER BY ttc.display_order, ttc.column_name`, + ON cl.table_name = ic.table_name AND cl.column_name = ic.column_name + WHERE cl.table_name = $1 + ORDER BY cl.column_name`, [tableName] ); diff --git a/backend-node/src/utils/businessNumberValidator.ts b/backend-node/src/utils/businessNumberValidator.ts new file mode 100644 index 00000000..92385f28 --- /dev/null +++ b/backend-node/src/utils/businessNumberValidator.ts @@ -0,0 +1,52 @@ +/** + * 사업자등록번호 유효성 검사 유틸리티 (백엔드) + */ + +/** + * 사업자등록번호 포맷 검증 + */ +export function validateBusinessNumberFormat(value: string): boolean { + if (!value || value.trim() === "") { + return false; + } + + // 하이픈 제거 + const cleaned = value.replace(/-/g, ""); + + // 숫자 10자리인지 확인 + if (!/^\d{10}$/.test(cleaned)) { + return false; + } + + return true; +} + +/** + * 사업자등록번호 종합 검증 (포맷만 검사) + * 실제 국세청 검증은 API 호출로 처리하는 것을 권장 + */ +export function validateBusinessNumber(value: string): { + isValid: boolean; + message: string; +} { + if (!value || value.trim() === "") { + return { + isValid: false, + message: "사업자등록번호를 입력해주세요.", + }; + } + + if (!validateBusinessNumberFormat(value)) { + return { + isValid: false, + message: "사업자등록번호는 10자리 숫자여야 합니다.", + }; + } + + // 포맷만 검증하고 통과 + return { + isValid: true, + message: "", + }; +} + diff --git a/docs/채번규칙_컴포넌트_구현_완료.md b/docs/채번규칙_컴포넌트_구현_완료.md new file mode 100644 index 00000000..880beb11 --- /dev/null +++ b/docs/채번규칙_컴포넌트_구현_완료.md @@ -0,0 +1,374 @@ +# 채번규칙 컴포넌트 구현 완료 + +> **작성일**: 2025-11-04 +> **상태**: 백엔드 및 프론트엔드 핵심 구현 완료 (화면관리 통합 대기) + +--- + +## 구현 개요 + +채번규칙(Numbering Rule) 컴포넌트는 시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다. + +**생성 코드 예시**: +- 제품 코드: `PROD-20251104-0001` +- 프로젝트 코드: `PRJ-2025-001` +- 거래처 코드: `CUST-A-0001` + +--- + +## 완료된 구현 항목 + +### 1. 데이터베이스 레이어 ✅ + +**파일**: `db/migrations/034_create_numbering_rules.sql` + +- [x] `numbering_rules` 마스터 테이블 생성 +- [x] `numbering_rule_parts` 파트 테이블 생성 +- [x] 멀티테넌시 지원 (company_code 필드) +- [x] 인덱스 생성 (성능 최적화) +- [x] 샘플 데이터 삽입 + +**주요 기능**: +- 규칙 ID, 규칙명, 구분자, 초기화 주기 +- 현재 시퀀스 번호 관리 +- 적용 대상 테이블/컬럼 지정 +- 최대 6개 파트 지원 + +--- + +### 2. 백엔드 레이어 ✅ + +#### 2.1 서비스 레이어 + +**파일**: `backend-node/src/services/numberingRuleService.ts` + +**구현된 메서드**: +- [x] `getRuleList(companyCode)` - 규칙 목록 조회 +- [x] `getRuleById(ruleId, companyCode)` - 특정 규칙 조회 +- [x] `createRule(config, companyCode, userId)` - 규칙 생성 +- [x] `updateRule(ruleId, updates, companyCode)` - 규칙 수정 +- [x] `deleteRule(ruleId, companyCode)` - 규칙 삭제 +- [x] `generateCode(ruleId, companyCode)` - 코드 생성 +- [x] `resetSequence(ruleId, companyCode)` - 시퀀스 초기화 + +**핵심 로직**: +- 트랜잭션 관리 (BEGIN/COMMIT/ROLLBACK) +- 멀티테넌시 필터링 (company_code 기반) +- JSON 설정 직렬화/역직렬화 +- 날짜 형식 변환 (YYYY, YYYYMMDD 등) +- 순번 자동 증가 및 제로 패딩 + +#### 2.2 컨트롤러 레이어 + +**파일**: `backend-node/src/controllers/numberingRuleController.ts` + +**구현된 엔드포인트**: +- [x] `GET /api/numbering-rules` - 규칙 목록 조회 +- [x] `GET /api/numbering-rules/:ruleId` - 특정 규칙 조회 +- [x] `POST /api/numbering-rules` - 규칙 생성 +- [x] `PUT /api/numbering-rules/:ruleId` - 규칙 수정 +- [x] `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제 +- [x] `POST /api/numbering-rules/:ruleId/generate` - 코드 생성 +- [x] `POST /api/numbering-rules/:ruleId/reset` - 시퀀스 초기화 + +**보안 및 검증**: +- `authenticateToken` 미들웨어로 인증 확인 +- 입력값 검증 (필수 필드, 파트 최소 개수) +- 에러 핸들링 및 적절한 HTTP 상태 코드 반환 + +#### 2.3 라우터 등록 + +**파일**: `backend-node/src/app.ts` + +```typescript +import numberingRuleController from "./controllers/numberingRuleController"; +app.use("/api/numbering-rules", numberingRuleController); +``` + +--- + +### 3. 프론트엔드 레이어 ✅ + +#### 3.1 타입 정의 + +**파일**: `frontend/types/numbering-rule.ts` + +**정의된 타입**: +- [x] `CodePartType` - 파트 유형 (prefix/sequence/date/year/month/custom) +- [x] `GenerationMethod` - 생성 방식 (auto/manual) +- [x] `DateFormat` - 날짜 형식 (YYYY/YYYYMMDD 등) +- [x] `NumberingRulePart` - 단일 파트 인터페이스 +- [x] `NumberingRuleConfig` - 전체 규칙 인터페이스 +- [x] 상수 옵션 배열 (UI용) + +#### 3.2 API 클라이언트 + +**파일**: `frontend/lib/api/numberingRule.ts` + +**구현된 함수**: +- [x] `getNumberingRules()` - 규칙 목록 조회 +- [x] `getNumberingRuleById(ruleId)` - 특정 규칙 조회 +- [x] `createNumberingRule(config)` - 규칙 생성 +- [x] `updateNumberingRule(ruleId, config)` - 규칙 수정 +- [x] `deleteNumberingRule(ruleId)` - 규칙 삭제 +- [x] `generateCode(ruleId)` - 코드 생성 +- [x] `resetSequence(ruleId)` - 시퀀스 초기화 + +**기술 스택**: +- Axios 기반 API 클라이언트 +- 에러 핸들링 및 응답 타입 정의 + +#### 3.3 컴포넌트 구조 + +``` +frontend/components/numbering-rule/ +├── NumberingRuleDesigner.tsx # 메인 디자이너 (좌우 분할) +├── NumberingRulePreview.tsx # 실시간 미리보기 +├── NumberingRuleCard.tsx # 단일 파트 카드 +├── AutoConfigPanel.tsx # 자동 생성 설정 +└── ManualConfigPanel.tsx # 직접 입력 설정 +``` + +#### 3.4 주요 컴포넌트 기능 + +**NumberingRuleDesigner** (메인 컴포넌트): +- [x] 좌측: 저장된 규칙 목록 (카드 리스트) +- [x] 우측: 규칙 편집 영역 (파트 추가/수정/삭제) +- [x] 실시간 미리보기 +- [x] 규칙 저장/불러오기/삭제 +- [x] 타이틀 편집 기능 +- [x] 로딩 상태 관리 + +**NumberingRulePreview**: +- [x] 설정된 규칙에 따라 실시간 코드 생성 +- [x] 컴팩트 모드 지원 +- [x] useMemo로 성능 최적화 + +**NumberingRuleCard**: +- [x] 파트 유형 선택 (Select) +- [x] 생성 방식 선택 (자동/수동) +- [x] 동적 설정 패널 표시 +- [x] 삭제 버튼 + +**AutoConfigPanel**: +- [x] 파트 유형별 설정 UI +- [x] 접두사, 순번, 날짜, 연도, 월, 커스텀 +- [x] 입력값 검증 및 가이드 텍스트 + +**ManualConfigPanel**: +- [x] 직접 입력값 설정 +- [x] 플레이스홀더 설정 + +--- + +## 기술적 특징 + +### Shadcn/ui 스타일 가이드 준수 + +- 반응형 크기: `h-8 sm:h-10`, `text-xs sm:text-sm` +- 색상 토큰: `bg-muted`, `text-muted-foreground`, `border-border` +- 간격: `space-y-3 sm:space-y-4`, `gap-4` +- 상태: `hover:bg-accent`, `disabled:opacity-50` + +### 실시간 속성 편집 패턴 + +```typescript +const [currentRule, setCurrentRule] = useState(null); + +useEffect(() => { + if (currentRule) { + onChange?.(currentRule); // 상위 컴포넌트로 실시간 전파 + } +}, [currentRule, onChange]); + +const handleUpdatePart = useCallback((partId: string, updates: Partial) => { + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)), + }; + }); +}, []); +``` + +### 멀티테넌시 지원 + +```typescript +// 백엔드 쿼리 +WHERE company_code = $1 OR company_code = '*' + +// 일반 회사는 자신의 데이터만 조회 +// company_code = "*"는 최고 관리자 전용 데이터 +``` + +### 에러 처리 및 사용자 피드백 + +```typescript +try { + const response = await createNumberingRule(config); + if (response.success) { + toast.success("채번 규칙이 저장되었습니다"); + } else { + toast.error(response.error || "저장 실패"); + } +} catch (error: any) { + toast.error(`저장 실패: ${error.message}`); +} +``` + +--- + +## 남은 작업 + +### 화면관리 시스템 통합 (TODO) + +다음 파일들을 생성하여 화면관리 시스템에 컴포넌트를 등록해야 합니다: + +``` +frontend/lib/registry/components/numbering-rule/ +├── index.ts # 컴포넌트 정의 및 등록 +├── NumberingRuleComponent.tsx # 래퍼 컴포넌트 +├── NumberingRuleConfigPanel.tsx # 속성 설정 패널 +└── types.ts # 컴포넌트 설정 타입 +``` + +**등록 예시**: +```typescript +export const NumberingRuleDefinition = createComponentDefinition({ + id: "numbering-rule", + name: "코드 채번 규칙", + category: ComponentCategory.ADMIN, + component: NumberingRuleWrapper, + configPanel: NumberingRuleConfigPanel, + defaultSize: { width: 1200, height: 800 }, + icon: "Hash", + tags: ["코드", "채번", "규칙", "관리자"], +}); +``` + +--- + +## 테스트 가이드 + +### 백엔드 API 테스트 (Postman/Thunder Client) + +#### 1. 규칙 목록 조회 +```bash +GET http://localhost:8080/api/numbering-rules +Authorization: Bearer {token} +``` + +#### 2. 규칙 생성 +```bash +POST http://localhost:8080/api/numbering-rules +Content-Type: application/json +Authorization: Bearer {token} + +{ + "ruleId": "PROD_CODE", + "ruleName": "제품 코드 규칙", + "separator": "-", + "parts": [ + { + "order": 1, + "partType": "prefix", + "generationMethod": "auto", + "autoConfig": { "prefix": "PROD" } + }, + { + "order": 2, + "partType": "date", + "generationMethod": "auto", + "autoConfig": { "dateFormat": "YYYYMMDD" } + }, + { + "order": 3, + "partType": "sequence", + "generationMethod": "auto", + "autoConfig": { "sequenceLength": 4, "startFrom": 1 } + } + ] +} +``` + +#### 3. 코드 생성 +```bash +POST http://localhost:8080/api/numbering-rules/PROD_CODE/generate +Authorization: Bearer {token} + +Response: +{ + "success": true, + "data": { + "code": "PROD-20251104-0001" + } +} +``` + +### 프론트엔드 테스트 + +1. **새 규칙 생성**: + - "새 규칙 생성" 버튼 클릭 + - 규칙명 입력 + - "규칙 추가" 버튼으로 파트 추가 + - 각 파트의 설정 변경 + - "저장" 버튼 클릭 + +2. **미리보기 확인**: + - 파트 추가/수정 시 실시간으로 코드 미리보기 업데이트 확인 + - 구분자 변경 시 반영 확인 + +3. **규칙 편집**: + - 좌측 목록에서 규칙 선택 + - 우측 편집 영역에서 수정 + - 저장 후 목록에 반영 확인 + +4. **규칙 삭제**: + - 목록 카드의 삭제 버튼 클릭 + - 목록에서 제거 확인 + +--- + +## 파일 목록 + +### 백엔드 +- `db/migrations/034_create_numbering_rules.sql` (마이그레이션) +- `backend-node/src/services/numberingRuleService.ts` (서비스) +- `backend-node/src/controllers/numberingRuleController.ts` (컨트롤러) +- `backend-node/src/app.ts` (라우터 등록) + +### 프론트엔드 +- `frontend/types/numbering-rule.ts` (타입 정의) +- `frontend/lib/api/numberingRule.ts` (API 클라이언트) +- `frontend/components/numbering-rule/NumberingRuleDesigner.tsx` +- `frontend/components/numbering-rule/NumberingRulePreview.tsx` +- `frontend/components/numbering-rule/NumberingRuleCard.tsx` +- `frontend/components/numbering-rule/AutoConfigPanel.tsx` +- `frontend/components/numbering-rule/ManualConfigPanel.tsx` + +--- + +## 다음 단계 + +1. **마이그레이션 실행**: + ```sql + psql -U postgres -d ilshin -f db/migrations/034_create_numbering_rules.sql + ``` + +2. **백엔드 서버 확인** (이미 실행 중이면 자동 반영) + +3. **화면관리 통합**: + - 레지스트리 컴포넌트 파일 생성 + - 컴포넌트 등록 및 화면 디자이너에서 사용 가능하도록 설정 + +4. **테스트**: + - API 테스트 (Postman) + - UI 테스트 (브라우저) + - 멀티테넌시 검증 + +--- + +**작성 완료**: 2025-11-04 +**문의**: 백엔드 및 프론트엔드 핵심 기능 완료, 화면관리 통합만 남음 + diff --git a/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx b/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx new file mode 100644 index 00000000..7854e6ee --- /dev/null +++ b/frontend/app/(main)/admin/company/[companyCode]/departments/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { DepartmentManagement } from "@/components/admin/department/DepartmentManagement"; + +export default function DepartmentManagementPage() { + const params = useParams(); + const companyCode = params.companyCode as string; + + return ; +} + diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index 8c55e366..d8eeae61 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -13,18 +13,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { useToast } from "@/hooks/use-toast"; import { Pagination, PaginationInfo } from "@/components/common/Pagination"; +import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react"; interface DashboardListClientProps { @@ -190,14 +181,19 @@ export default function DashboardListClient({ initialDashboards, initialPaginati <> {/* 검색 및 액션 */}
-
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - /> +
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
+ 총 {totalCount.toLocaleString()} 건 +
+ + + router.push(`/admin/dashboard/edit/${dashboard.id}`)} + className="gap-2 text-sm" + > + + 편집 + + handleCopy(dashboard)} className="gap-2 text-sm"> + + 복사 + + handleDeleteClick(dashboard.id, dashboard.title)} + className="text-destructive focus:text-destructive gap-2 text-sm" + > + + 삭제 + + + + + + ))} + + +
+ + {/* 모바일/태블릿 카드 뷰 (lg 미만) */} +
+ {dashboards.map((dashboard) => ( +
+ {/* 헤더 */} +
+
+

{dashboard.title}

+

{dashboard.id}

+
+
+ + {/* 정보 */} +
+
+ 설명 + {dashboard.description || "-"} +
+
+ 생성일 + {formatDate(dashboard.createdAt)} +
+
+ 수정일 + {formatDate(dashboard.updatedAt)} +
+
+ + {/* 액션 */} +
+ + + +
+
+ ))} +
+ )} {/* 페이지네이션 */} @@ -307,26 +423,18 @@ export default function DashboardListClient({ initialDashboards, initialPaginati )} {/* 삭제 확인 모달 */} - - - - 대시보드 삭제 - - "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? -
이 작업은 되돌릴 수 없습니다. -
-
- - 취소 - - 삭제 - - -
-
+ + "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. + + } + onConfirm={handleDeleteConfirm} + /> ); } diff --git a/frontend/app/(main)/admin/dashboard/new/page.tsx b/frontend/app/(main)/admin/dashboard/new/page.tsx index d2f2ce11..56d28f46 100644 --- a/frontend/app/(main)/admin/dashboard/new/page.tsx +++ b/frontend/app/(main)/admin/dashboard/new/page.tsx @@ -1,6 +1,3 @@ -"use client"; - -import React from "react"; import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner"; /** diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index dac590d6..5a4b3352 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -26,7 +26,7 @@ export default function ScreenViewPage() { // 🆕 현재 로그인한 사용자 정보 const { user, userName, companyCode } = useAuth(); - + // 🆕 모바일 환경 감지 const { isMobile } = useResponsive(); @@ -61,6 +61,9 @@ export default function ScreenViewPage() { modalDescription?: string; }>({}); + // 레이아웃 준비 완료 상태 (버튼 위치 계산 완료 후 화면 표시) + const [layoutReady, setLayoutReady] = useState(true); + const containerRef = React.useRef(null); const [scale, setScale] = useState(1); const [containerWidth, setContainerWidth] = useState(0); @@ -106,6 +109,7 @@ export default function ScreenViewPage() { const loadScreen = async () => { try { setLoading(true); + setLayoutReady(false); // 화면 로드 시 레이아웃 준비 초기화 setError(null); // 화면 정보 로드 @@ -147,6 +151,57 @@ export default function ScreenViewPage() { } }, [screenId]); + // 🆕 autoFill 자동 입력 초기화 + useEffect(() => { + const initAutoFill = async () => { + if (!layout || !layout.components || !user) { + return; + } + + for (const comp of layout.components) { + // type: "component" 또는 type: "widget" 모두 처리 + if (comp.type === 'widget' || comp.type === 'component') { + const widget = comp as any; + const fieldName = widget.columnName || widget.id; + + // autoFill 처리 + if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { + const autoFillConfig = widget.autoFill || (comp as any).autoFill; + const currentValue = formData[fieldName]; + + if (currentValue === undefined || currentValue === '') { + const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; + + // 사용자 정보에서 필터 값 가져오기 + const userValue = user?.[userField as keyof typeof user]; + + if (userValue && sourceTable && filterColumn && displayColumn) { + try { + const { tableTypeApi } = await import("@/lib/api/screen"); + const result = await tableTypeApi.getTableRecord( + sourceTable, + filterColumn, + userValue, + displayColumn + ); + + setFormData((prev) => ({ + ...prev, + [fieldName]: result.value, + })); + } catch (error) { + console.error(`autoFill 조회 실패: ${fieldName}`, error); + } + } + } + } + } + } + }; + + initAutoFill(); + }, [layout, user]); + // 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 모바일에서는 비활성화 useEffect(() => { // 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동) @@ -174,6 +229,9 @@ export default function ScreenViewPage() { setScale(newScale); // 컨테이너 너비 업데이트 setContainerWidth(containerWidth); + + // 스케일 계산 완료 후 레이아웃 준비 완료 표시 + setLayoutReady(true); } }; @@ -189,10 +247,10 @@ export default function ScreenViewPage() { if (loading) { return ( -
-
- -

화면을 불러오는 중...

+
+
+ +

화면을 불러오는 중...

); @@ -200,13 +258,13 @@ export default function ScreenViewPage() { if (error || !screen) { return ( -
-
-
+
+
+
⚠️
-

화면을 찾을 수 없습니다

-

{error || "요청하신 화면이 존재하지 않습니다."}

+

화면을 찾을 수 없습니다

+

{error || "요청하신 화면이 존재하지 않습니다."}

@@ -222,10 +280,20 @@ export default function ScreenViewPage() { return (
+ {/* 레이아웃 준비 중 로딩 표시 */} + {!layoutReady && ( +
+
+ +

화면 준비 중...

+
+
+ )} + {/* 절대 위치 기반 렌더링 */} - {layout && layout.components.length > 0 ? ( + {layoutReady && layout && layout.components.length > 0 ? (
!component.parentId); + // 버튼은 scale에 맞춰 위치만 조정하면 됨 (scale = 1.0이면 그대로, scale < 1.0이면 왼쪽으로) + // 하지만 x=0 컴포넌트는 width: 100%로 확장되므로, 그만큼 버튼을 오른쪽으로 이동 + const leftmostComponent = topLevelComponents.find((c) => c.position.x === 0); + let widthOffset = 0; + + if (leftmostComponent && containerWidth > 0) { + const originalWidth = leftmostComponent.size?.width || screenWidth; + const actualWidth = containerWidth / scale; + widthOffset = Math.max(0, actualWidth - originalWidth); + + console.log("📊 widthOffset 계산:", { + containerWidth, + scale, + screenWidth, + originalWidth, + actualWidth, + widthOffset, + leftmostType: leftmostComponent.type, + }); + } + const buttonGroups: Record = {}; const processedButtonIds = new Set(); + // 🔍 전체 버튼 목록 확인 + const allButtons = topLevelComponents.filter((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); + return isButton; + }); + + console.log( + "🔍 메뉴에서 발견된 전체 버튼:", + allButtons.map((b) => ({ + id: b.id, + label: b.label, + positionX: b.position.x, + positionY: b.position.y, + })), + ); topLevelComponents.forEach((component) => { const isButton = - component.type === "button" || (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)); + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); if (isButton) { const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as | FlowVisibilityConfig | undefined; - if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) { + // 🔧 임시: 버튼 그룹 기능 완전 비활성화 + // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 + const DISABLE_BUTTON_GROUPS = false; + + if ( + !DISABLE_BUTTON_GROUPS && + flowConfig?.enabled && + flowConfig.layoutBehavior === "auto-compact" && + flowConfig.groupId + ) { if (!buttonGroups[flowConfig.groupId]) { buttonGroups[flowConfig.groupId] = []; } buttonGroups[flowConfig.groupId].push(component); processedButtonIds.add(component.id); } + // else: 모든 버튼을 개별 렌더링 } }); @@ -267,92 +384,121 @@ export default function ScreenViewPage() { return ( <> {/* 일반 컴포넌트들 */} - {regularComponents.map((component) => ( - {}} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { - console.log("🔍 화면에서 선택된 행 데이터:", selectedData); - setSelectedRowsData(selectedData); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); // 선택 해제 - setFlowSelectedStepId(null); - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - > - {/* 자식 컴포넌트들 */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...child, - position: { - x: child.position.x - component.position.x, - y: child.position.y - component.position.y, - z: child.position.z || 1, - }, - }; + {regularComponents.map((component) => { + // 버튼인 경우 위치 조정 (테이블이 늘어난 만큼 오른쪽으로 이동) + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); - return ( - {}} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { - console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); - setSelectedRowsData(selectedData); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - console.log("🔄 테이블 새로고침 요청됨 (자식)"); - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> - ); - })} - - ))} + const adjustedComponent = + isButton && widthOffset > 0 + ? { + ...component, + position: { + ...component.position, + x: component.position.x + widthOffset, + }, + } + : component; + + // 버튼일 경우 로그 출력 + if (isButton) { + console.log("🔘 버튼 위치 조정:", { + label: component.label, + originalX: component.position.x, + adjustedX: component.position.x + widthOffset, + widthOffset, + }); + } + + return ( + {}} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={(_, selectedData) => { + console.log("🔍 화면에서 선택된 행 데이터:", selectedData); + setSelectedRowsData(selectedData); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); // 선택 해제 + setFlowSelectedStepId(null); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || component.type === "container" || component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; + + return ( + {}} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + selectedRowsData={selectedRowsData} + onSelectedRowsChange={(_, selectedData) => { + console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); + setSelectedRowsData(selectedData); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + console.log("🔄 테이블 새로고침 요청됨 (자식)"); + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> + ); + })} + + ); + })} {/* 🆕 플로우 버튼 그룹들 */} {Object.entries(buttonGroups).map(([groupId, buttons]) => { @@ -362,15 +508,37 @@ export default function ScreenViewPage() { const groupConfig = (firstButton as any).webTypeConfig ?.flowVisibilityConfig as FlowVisibilityConfig; - // 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용 - const groupPosition = buttons.reduce( - (min, button) => ({ - x: Math.min(min.x, button.position.x), - y: Math.min(min.y, button.position.y), - z: min.z, - }), - { x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 }, - ); + // 🔍 버튼 그룹 설정 확인 + console.log("🔍 버튼 그룹 설정:", { + groupId, + buttonCount: buttons.length, + buttons: buttons.map((b) => ({ + id: b.id, + label: b.label, + x: b.position.x, + y: b.position.y, + })), + groupConfig: { + layoutBehavior: groupConfig.layoutBehavior, + groupDirection: groupConfig.groupDirection, + groupAlign: groupConfig.groupAlign, + groupGap: groupConfig.groupGap, + }, + }); + + // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, + // 각 버튼의 상대 위치는 원래 위치를 유지 + const firstButtonPosition = { + x: buttons[0].position.x, + y: buttons[0].position.y, + z: buttons[0].position.z || 2, + }; + + // 버튼 그룹 위치에도 widthOffset 적용 + const adjustedGroupPosition = { + ...firstButtonPosition, + x: firstButtonPosition.x + widthOffset, + }; // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 const direction = groupConfig.groupDirection || "horizontal"; @@ -400,9 +568,9 @@ export default function ScreenViewPage() { key={`flow-button-group-${groupId}`} style={{ position: "absolute", - left: `${groupPosition.x}px`, - top: `${groupPosition.y}px`, - zIndex: groupPosition.z, + left: `${adjustedGroupPosition.x}px`, + top: `${adjustedGroupPosition.y}px`, + zIndex: adjustedGroupPosition.z, width: `${groupWidth}px`, height: `${groupHeight}px`, }} @@ -412,9 +580,14 @@ export default function ScreenViewPage() { groupConfig={groupConfig} isDesignMode={false} renderButton={(button) => { + // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 const relativeButton = { ...button, - position: { x: 0, y: 0, z: button.position.z || 1 }, + position: { + x: button.position.x - firstButtonPosition.x, + y: button.position.y - firstButtonPosition.y, + z: button.position.z || 1, + }, }; return ( diff --git a/frontend/components/admin/CompanyFormModal.tsx b/frontend/components/admin/CompanyFormModal.tsx index 7b51ac6a..dd87140e 100644 --- a/frontend/components/admin/CompanyFormModal.tsx +++ b/frontend/components/admin/CompanyFormModal.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; +import { validateBusinessNumber, formatBusinessNumber } from "@/lib/validation/businessNumber"; interface CompanyFormModalProps { modalState: CompanyModalState; @@ -29,6 +30,7 @@ export function CompanyFormModal({ onClearError, }: CompanyFormModalProps) { const [isSaving, setIsSaving] = useState(false); + const [businessNumberError, setBusinessNumberError] = useState(""); // 모달이 열려있지 않으면 렌더링하지 않음 if (!modalState.isOpen) return null; @@ -36,15 +38,43 @@ export function CompanyFormModal({ const { mode, formData, selectedCompany } = modalState; const isEditMode = mode === "edit"; + // 사업자등록번호 변경 처리 + const handleBusinessNumberChange = (value: string) => { + // 자동 포맷팅 + const formatted = formatBusinessNumber(value); + onFormChange("business_registration_number", formatted); + + // 유효성 검사 (10자리가 다 입력되었을 때만) + const cleaned = formatted.replace(/-/g, ""); + if (cleaned.length === 10) { + const validation = validateBusinessNumber(formatted); + setBusinessNumberError(validation.isValid ? "" : validation.message); + } else if (cleaned.length < 10 && businessNumberError) { + // 10자리 미만이면 에러 초기화 + setBusinessNumberError(""); + } + }; + // 저장 처리 const handleSave = async () => { - // 입력값 검증 + // 입력값 검증 (필수 필드) if (!formData.company_name.trim()) { return; } + if (!formData.business_registration_number.trim()) { + return; + } + + // 사업자등록번호 최종 검증 + const validation = validateBusinessNumber(formData.business_registration_number); + if (!validation.isValid) { + setBusinessNumberError(validation.message); + return; + } setIsSaving(true); onClearError(); + setBusinessNumberError(""); try { const success = await onSave(); @@ -81,7 +111,7 @@ export function CompanyFormModal({
- {/* 회사명 입력 */} + {/* 회사명 입력 (필수) */}
+ {/* 사업자등록번호 입력 (필수) */} +
+ + handleBusinessNumberChange(e.target.value)} + placeholder="000-00-00000" + disabled={isLoading || isSaving} + maxLength={12} + className={businessNumberError ? "border-destructive" : ""} + /> + {businessNumberError ? ( +

{businessNumberError}

+ ) : ( +

10자리 숫자 (자동 하이픈 추가)

+ )} +
+ + {/* 대표자명 입력 */} +
+ + onFormChange("representative_name", e.target.value)} + placeholder="대표자명을 입력하세요" + disabled={isLoading || isSaving} + /> +
+ + {/* 대표 연락처 입력 */} +
+ + onFormChange("representative_phone", e.target.value)} + placeholder="010-0000-0000" + disabled={isLoading || isSaving} + type="tel" + /> +
+ + {/* 이메일 입력 */} +
+ + onFormChange("email", e.target.value)} + placeholder="company@example.com" + disabled={isLoading || isSaving} + type="email" + /> +
+ + {/* 웹사이트 입력 */} +
+ + onFormChange("website", e.target.value)} + placeholder="https://example.com" + disabled={isLoading || isSaving} + type="url" + /> +
+ + {/* 회사 주소 입력 */} +
+ + onFormChange("address", e.target.value)} + placeholder="서울특별시 강남구..." + disabled={isLoading || isSaving} + /> +
+ {/* 에러 메시지 */} {error && ( -
-

{error}

+
+

{error}

)} @@ -129,7 +243,13 @@ export function CompanyFormModal({ + + + + + ); +}; + diff --git a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx index 74869ef0..f7d97779 100644 --- a/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx @@ -1,18 +1,46 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; -import { Button } from "@/components/ui/button"; -import { Loader2, RefreshCw } from "lucide-react"; -import { applyColumnMapping } from "@/lib/utils/columnMapping"; +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; import { getApiUrl } from "@/lib/utils/apiUrl"; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; interface CustomMetricTestWidgetProps { element: DashboardElement; } +// 필터 적용 함수 +const applyFilters = (rows: any[], filters?: Array<{ column: string; operator: string; value: string }>): any[] => { + if (!filters || filters.length === 0) return rows; + + return rows.filter((row) => { + return filters.every((filter) => { + const cellValue = String(row[filter.column] || ""); + const filterValue = filter.value; + + switch (filter.operator) { + case "=": + return cellValue === filterValue; + case "!=": + return cellValue !== filterValue; + case ">": + return parseFloat(cellValue) > parseFloat(filterValue); + case "<": + return parseFloat(cellValue) < parseFloat(filterValue); + case ">=": + return parseFloat(cellValue) >= parseFloat(filterValue); + case "<=": + return parseFloat(cellValue) <= parseFloat(filterValue); + case "contains": + return cellValue.includes(filterValue); + case "not_contains": + return !cellValue.includes(filterValue); + default: + return true; + } + }); + }); +}; + // 집계 함수 실행 const calculateMetric = (rows: any[], field: string, aggregation: string): number => { if (rows.length === 0) return 0; @@ -38,372 +66,138 @@ const calculateMetric = (rows: any[], field: string, aggregation: string): numbe } }; -// 색상 스타일 매핑 (차분한 색상) -const colorMap = { - indigo: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - green: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, -}; - -/** - * 통계 카드 위젯 (다중 데이터 소스 지원) - * - 여러 REST API 연결 가능 - * - 여러 Database 연결 가능 - * - REST API + Database 혼합 가능 - * - 데이터 자동 병합 후 집계 - */ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) { - const [metrics, setMetrics] = useState([]); - const [groupedCards, setGroupedCards] = useState>([]); - const [loading, setLoading] = useState(false); + const [value, setValue] = useState(0); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [lastRefreshTime, setLastRefreshTime] = useState(null); - const [selectedMetric, setSelectedMetric] = useState(null); - const [isDetailOpen, setIsDetailOpen] = useState(false); - // console.log("🧪 CustomMetricTestWidget 렌더링!", element); + const config = element?.customMetricConfig; - const dataSources = useMemo(() => { - return element?.dataSources || element?.chartConfig?.dataSources; - }, [element?.dataSources, element?.chartConfig?.dataSources]); + console.log("📊 [CustomMetricTestWidget] 렌더링:", { + element, + config, + dataSource: element?.dataSource, + }); - // 🆕 그룹별 카드 모드 체크 - const isGroupByMode = element?.customMetricConfig?.groupByMode || false; + useEffect(() => { + loadData(); - // 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션 - const metricConfig = useMemo(() => { - return ( - element?.customMetricConfig?.metrics || [ - { - label: "총 개수", - field: "id", - aggregation: "count", - color: "indigo", - }, - ] - ); - }, [element?.customMetricConfig?.metrics]); + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [element]); - // 🆕 그룹별 카드 데이터 로드 (원본에서 복사) - const loadGroupByData = useCallback(async () => { - const groupByDS = element?.customMetricConfig?.groupByDataSource; - if (!groupByDS) return; - - const dataSourceType = groupByDS.type; - - // Database 타입 - if (dataSourceType === "database") { - if (!groupByDS.query) return; - - const { dashboardApi } = await import("@/lib/api/dashboard"); - const result = await dashboardApi.executeQuery(groupByDS.query); - - if (result && result.rows) { - const rows = result.rows; - if (rows.length > 0) { - const columns = result.columns || Object.keys(rows[0]); - const labelColumn = columns[0]; - const valueColumn = columns[1]; - - const cards = rows.map((row: any) => ({ - label: String(row[labelColumn] || ""), - value: parseFloat(row[valueColumn]) || 0, - })); - - setGroupedCards(cards); - } - } - } - // API 타입 - else if (dataSourceType === "api") { - if (!groupByDS.endpoint) return; - - // REST API 호출 (백엔드 프록시 사용) - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: groupByDS.endpoint, - method: "GET", - headers: (groupByDS as any).headers || {}, - }), - }); - const result = await response.json(); - - if (result.success && result.data) { - let rows: any[] = []; - if (Array.isArray(result.data)) { - rows = result.data; - } else if (result.data.results && Array.isArray(result.data.results)) { - rows = result.data.results; - } else if (result.data.items && Array.isArray(result.data.items)) { - rows = result.data.items; - } else if (result.data.data && Array.isArray(result.data.data)) { - rows = result.data.data; - } else { - rows = [result.data]; - } - - if (rows.length > 0) { - const columns = Object.keys(rows[0]); - const labelColumn = columns[0]; - const valueColumn = columns[1]; - - const cards = rows.map((row: any) => ({ - label: String(row[labelColumn] || ""), - value: parseFloat(row[valueColumn]) || 0, - })); - - setGroupedCards(cards); - } - } - } - }, [element?.customMetricConfig?.groupByDataSource]); - - // 다중 데이터 소스 로딩 - const loadMultipleDataSources = useCallback(async () => { - const dataSources = element?.dataSources || element?.chartConfig?.dataSources; - - if (!dataSources || dataSources.length === 0) { - // console.log("⚠️ 데이터 소스가 없습니다."); - return; - } - - // console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`); - setLoading(true); - setError(null); - - try { - // 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리) - const results = await Promise.allSettled( - dataSources.map(async (source, sourceIndex) => { - try { - // console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`); - - let rows: any[] = []; - if (source.type === "api") { - rows = await loadRestApiData(source); - } else if (source.type === "database") { - rows = await loadDatabaseData(source); - } - - // console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`); - - return { - sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`, - sourceIndex: sourceIndex, - rows: rows, - }; - } catch (err: any) { - console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err); - return { - sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`, - sourceIndex: sourceIndex, - rows: [], - }; - } - }), - ); - - // console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`); - - // 각 데이터 소스별로 메트릭 생성 - const allMetrics: any[] = []; - const colors = ["indigo", "green", "blue", "purple", "orange", "gray"]; - - results.forEach((result) => { - if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) { - return; - } - - const { sourceName, rows } = result.value; - - // 🎯 간단한 쿼리도 잘 작동하도록 개선된 로직 - if (rows.length > 0) { - const firstRow = rows[0]; - const columns = Object.keys(firstRow); - - // 숫자 컬럼 찾기 - const numericColumns = columns.filter((col) => { - const value = firstRow[col]; - return typeof value === "number" || !isNaN(Number(value)); - }); - - // 문자열 컬럼 찾기 - const stringColumns = columns.filter((col) => !numericColumns.includes(col)); - - // 🎯 케이스 0: 1행인데 숫자 컬럼이 여러 개 → 각 컬럼을 별도 카드로 - if (rows.length === 1 && numericColumns.length > 1) { - // 예: SELECT COUNT(*) AS 전체, SUM(...) AS 배송중, ... - numericColumns.forEach((col) => { - allMetrics.push({ - label: col, // 컬럼명이 라벨 - value: Number(firstRow[col]) || 0, - field: col, - aggregation: "custom", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: [firstRow], - }); - }); - } - // 🎯 케이스 1: 컬럼이 2개 (라벨 + 값) → 가장 간단한 형태 - else if (columns.length === 2) { - const labelCol = columns[0]; - const valueCol = columns[1]; - - rows.forEach((row) => { - allMetrics.push({ - label: String(row[labelCol] || ""), - value: Number(row[valueCol]) || 0, - field: valueCol, - aggregation: "custom", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: [row], - }); - }); - } - // 🎯 케이스 2: 숫자 컬럼이 1개 이상 있음 → 집계된 데이터 - else if (numericColumns.length > 0) { - rows.forEach((row, index) => { - // 라벨: 첫 번째 문자열 컬럼 (없으면 첫 번째 컬럼) - const labelField = stringColumns[0] || columns[0]; - const label = String(row[labelField] || `항목 ${index + 1}`); - - // 값: 첫 번째 숫자 컬럼 - const valueField = numericColumns[0]; - const value = Number(row[valueField]) || 0; - - allMetrics.push({ - label: label, - value: value, - field: valueField, - aggregation: "custom", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: [row], - }); - }); - } - // 🎯 케이스 3: 숫자 컬럼이 없음 → 마지막 컬럼 기준으로 카운트 - else { - const aggregateField = columns[columns.length - 1]; - const countMap = new Map(); - - rows.forEach((row) => { - const value = String(row[aggregateField] || "기타"); - countMap.set(value, (countMap.get(value) || 0) + 1); - }); - - countMap.forEach((count, label) => { - allMetrics.push({ - label: label, - value: count, - field: aggregateField, - aggregation: "count", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows.filter((row) => String(row[aggregateField]) === label), - }); - }); - - // 전체 개수도 추가 - allMetrics.push({ - label: "전체", - value: rows.length, - field: "count", - aggregation: "count", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows, - }); - } - - } - // 🎯 행이 많을 때도 간단하게 처리 - else if (rows.length > 100) { - // 행이 많으면 총 개수만 표시 - // console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`); - - const firstRow = rows[0]; - const columns = Object.keys(firstRow); - - // 데이터 소스에서 선택된 컬럼 가져오기 - const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find( - (ds) => ds.name === sourceName || (result.status === "fulfilled" && ds.id === result.value?.sourceIndex.toString()), - ); - const selectedColumns = dataSourceConfig?.selectedColumns || []; - - // 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시 - const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns; - - // console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow); - - // 각 컬럼별 고유값 개수 - columnsToShow.forEach((col) => { - // 해당 컬럼이 실제로 존재하는지 확인 - if (!columns.includes(col)) { - console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`); - return; - } - - const uniqueValues = new Set(rows.map((row) => row[col])); - const uniqueCount = uniqueValues.size; - - // console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`); - - allMetrics.push({ - label: `${sourceName} - ${col} (고유값)`, - value: uniqueCount, - field: col, - aggregation: "distinct", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows, // 원본 데이터 저장 - }); - }); - - // 총 행 개수 - allMetrics.push({ - label: `${sourceName} - 총 개수`, - value: rows.length, - field: "count", - aggregation: "count", - color: colors[allMetrics.length % colors.length], - sourceName: sourceName, - rawData: rows, // 원본 데이터 저장 - }); - } - }); - - // console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`); - setMetrics(allMetrics); - setLastRefreshTime(new Date()); - } catch (err) { - setError(err instanceof Error ? err.message : "데이터 로딩 실패"); - } finally { - setLoading(false); - } - }, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]); - - // 🆕 통합 데이터 로딩 (그룹별 카드 + 일반 메트릭) - const loadAllData = useCallback(async () => { + const loadData = async () => { try { setLoading(true); setError(null); - // 그룹별 카드 데이터 로드 - if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) { - await loadGroupByData(); - } + const dataSourceType = element?.dataSource?.type; - // 일반 메트릭 데이터 로드 - if (dataSources && dataSources.length > 0) { - await loadMultipleDataSources(); + // Database 타입 + if (dataSourceType === "database") { + if (!element?.dataSource?.query) { + setValue(0); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: (element.dataSource as any).connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + let rows = result.data.rows; + + // 필터 적용 + if (config?.filters && config.filters.length > 0) { + rows = applyFilters(rows, config.filters); + } + + // 집계 계산 + if (config?.valueColumn && config?.aggregation) { + const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); + setValue(calculatedValue); + } else { + setValue(0); + } + } else { + throw new Error(result.message || "데이터 로드 실패"); + } + } + // API 타입 + else if (dataSourceType === "api") { + if (!element?.dataSource?.endpoint) { + setValue(0); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + method: (element.dataSource as any).method || "GET", + url: element.dataSource.endpoint, + headers: (element.dataSource as any).headers || {}, + body: (element.dataSource as any).body, + authType: (element.dataSource as any).authType, + authConfig: (element.dataSource as any).authConfig, + }), + }); + + if (!response.ok) throw new Error("API 호출 실패"); + + const result = await response.json(); + + if (result.success && result.data) { + let rows: any[] = []; + + // API 응답 데이터 구조 확인 및 처리 + if (Array.isArray(result.data)) { + rows = result.data; + } else if (result.data.results && Array.isArray(result.data.results)) { + rows = result.data.results; + } else if (result.data.items && Array.isArray(result.data.items)) { + rows = result.data.items; + } else if (result.data.data && Array.isArray(result.data.data)) { + rows = result.data.data; + } else { + rows = [result.data]; + } + + // 필터 적용 + if (config?.filters && config.filters.length > 0) { + rows = applyFilters(rows, config.filters); + } + + // 집계 계산 + if (config?.valueColumn && config?.aggregation) { + const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); + setValue(calculatedValue); + } else { + setValue(0); + } + } else { + throw new Error("API 응답 형식 오류"); + } } } catch (err) { console.error("데이터 로드 실패:", err); @@ -411,256 +205,11 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg } finally { setLoading(false); } - }, [ - isGroupByMode, - element?.customMetricConfig?.groupByDataSource, - dataSources, - loadGroupByData, - loadMultipleDataSources, - ]); - - // 수동 새로고침 핸들러 - const handleManualRefresh = useCallback(() => { - // console.log("🔄 수동 새로고침 버튼 클릭"); - loadAllData(); - }, [loadAllData]); - - // XML 데이터 파싱 - const parseXmlData = (xmlText: string): any[] => { - // console.log("🔍 XML 파싱 시작"); - try { - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(xmlText, "text/xml"); - - const records = xmlDoc.getElementsByTagName("record"); - const result: any[] = []; - - for (let i = 0; i < records.length; i++) { - const record = records[i]; - const obj: any = {}; - - for (let j = 0; j < record.children.length; j++) { - const child = record.children[j]; - obj[child.tagName] = child.textContent || ""; - } - - result.push(obj); - } - - // console.log(`✅ XML 파싱 완료: ${result.length}개 레코드`); - return result; - } catch (error) { - console.error("❌ XML 파싱 실패:", error); - throw new Error("XML 파싱 실패"); - } }; - // 텍스트/CSV 데이터 파싱 - const parseTextData = (text: string): any[] => { - // console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500)); - - // XML 감지 - if (text.trim().startsWith("")) { - // console.log("📄 XML 형식 감지"); - return parseXmlData(text); - } - - // CSV 파싱 - // console.log("📄 CSV 형식으로 파싱 시도"); - const lines = text.trim().split("\n"); - if (lines.length === 0) return []; - - const headers = lines[0].split(",").map((h) => h.trim()); - const result: any[] = []; - - for (let i = 1; i < lines.length; i++) { - const values = lines[i].split(","); - const obj: any = {}; - - headers.forEach((header, index) => { - obj[header] = values[index]?.trim() || ""; - }); - - result.push(obj); - } - - // console.log(`✅ CSV 파싱 완료: ${result.length}개 행`); - return result; - }; - - // REST API 데이터 로딩 - const loadRestApiData = async (source: ChartDataSource): Promise => { - if (!source.endpoint) { - throw new Error("API endpoint가 없습니다."); - } - - const params = new URLSearchParams(); - - // queryParams 배열 또는 객체 처리 - if (source.queryParams) { - if (Array.isArray(source.queryParams)) { - source.queryParams.forEach((param: any) => { - if (param.key && param.value) { - params.append(param.key, String(param.value)); - } - }); - } else { - Object.entries(source.queryParams).forEach(([key, value]) => { - if (key && value) { - params.append(key, String(value)); - } - }); - } - } - - // console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params)); - - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - url: source.endpoint, - method: "GET", - headers: source.headers || {}, - queryParams: Object.fromEntries(params), - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("❌ API 호출 실패:", { - status: response.status, - statusText: response.statusText, - body: errorText.substring(0, 500), - }); - throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 100)}`); - } - - const result = await response.json(); - // console.log("✅ API 응답:", result); - - if (!result.success) { - console.error("❌ API 실패:", result); - throw new Error(result.message || result.error || "외부 API 호출 실패"); - } - - let processedData = result.data; - - // 텍스트/XML 데이터 처리 - if (typeof processedData === "string") { - // console.log("📄 텍스트 형식 데이터 감지"); - processedData = parseTextData(processedData); - } else if (processedData && typeof processedData === "object" && processedData.text) { - // console.log("📄 래핑된 텍스트 데이터 감지"); - processedData = parseTextData(processedData.text); - } - - // JSON Path 처리 - if (source.jsonPath) { - const paths = source.jsonPath.split("."); - for (const path of paths) { - if (processedData && typeof processedData === "object" && path in processedData) { - processedData = processedData[path]; - } else { - throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`); - } - } - } else if (!Array.isArray(processedData) && typeof processedData === "object") { - // JSON Path 없으면 자동으로 배열 찾기 - // console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도"); - const arrayKeys = ["data", "items", "result", "records", "rows", "list"]; - - for (const key of arrayKeys) { - if (Array.isArray(processedData[key])) { - // console.log(`✅ 배열 발견: ${key}`); - processedData = processedData[key]; - break; - } - } - } - - const rows = Array.isArray(processedData) ? processedData : [processedData]; - - // 컬럼 매핑 적용 - return applyColumnMapping(rows, source.columnMapping); - }; - - // Database 데이터 로딩 - const loadDatabaseData = async (source: ChartDataSource): Promise => { - if (!source.query) { - throw new Error("SQL 쿼리가 없습니다."); - } - - let rows: any[] = []; - - if (source.connectionType === "external" && source.externalConnectionId) { - // 외부 DB - const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); - const externalResult = await ExternalDbConnectionAPI.executeQuery( - parseInt(source.externalConnectionId), - source.query, - ); - - if (!externalResult.success || !externalResult.data) { - throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패"); - } - - const resultData = externalResult.data as unknown as { - rows: Record[]; - }; - - rows = resultData.rows; - } else { - // 현재 DB - const { dashboardApi } = await import("@/lib/api/dashboard"); - const result = await dashboardApi.executeQuery(source.query); - - rows = result.rows; - } - - // 컬럼 매핑 적용 - return applyColumnMapping(rows, source.columnMapping); - }; - - // 초기 로드 (🆕 loadAllData 사용) - useEffect(() => { - if ((dataSources && dataSources.length > 0) || (isGroupByMode && element?.customMetricConfig?.groupByDataSource)) { - loadAllData(); - } - }, [dataSources, isGroupByMode, element?.customMetricConfig?.groupByDataSource, loadAllData]); - - // 자동 새로고침 (🆕 loadAllData 사용) - useEffect(() => { - if (!dataSources || dataSources.length === 0) return; - - const intervals = dataSources - .map((ds) => ds.refreshInterval) - .filter((interval): interval is number => typeof interval === "number" && interval > 0); - - if (intervals.length === 0) return; - - const minInterval = Math.min(...intervals); - // console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`); - - const intervalId = setInterval(() => { - // console.log("🔄 자동 새로고침 실행"); - loadAllData(); - }, minInterval * 1000); - - return () => { - // console.log("⏹️ 자동 새로고침 정리"); - clearInterval(intervalId); - }; - }, [dataSources, loadAllData]); - - // renderMetricCard 함수 제거 - 인라인으로 렌더링 - - // 로딩 상태 (원본 스타일) if (loading) { return ( -
+

데이터 로딩 중...

@@ -669,14 +218,13 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg ); } - // 에러 상태 (원본 스타일) if (error) { return ( -
+

⚠️ {error}

+ ); + } + + // 소수점 자릿수 (기본: 0) + const decimals = config?.decimals ?? 0; + const formattedValue = value.toFixed(decimals); + + // 통계 카드 렌더링 (전체 크기 꽉 차게) + return ( +
+ {/* 제목 */} +
{config?.title || "통계"}
+ + {/* 값 */} +
+ {formattedValue} + {config?.unit && {config.unit}} +
+ + {/* 필터 표시 (디버깅용, 작게) */} + {config?.filters && config.filters.length > 0 && ( +
필터: {config.filters.length}개 적용됨
+ )}
); } diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index b284aa14..3f3538b4 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -8,6 +8,39 @@ interface CustomMetricWidgetProps { element?: DashboardElement; } +// 필터 적용 함수 +const applyFilters = (rows: any[], filters?: Array<{ column: string; operator: string; value: string }>): any[] => { + if (!filters || filters.length === 0) return rows; + + return rows.filter((row) => { + return filters.every((filter) => { + const cellValue = String(row[filter.column] || ""); + const filterValue = filter.value; + + switch (filter.operator) { + case "=": + return cellValue === filterValue; + case "!=": + return cellValue !== filterValue; + case ">": + return parseFloat(cellValue) > parseFloat(filterValue); + case "<": + return parseFloat(cellValue) < parseFloat(filterValue); + case ">=": + return parseFloat(cellValue) >= parseFloat(filterValue); + case "<=": + return parseFloat(cellValue) <= parseFloat(filterValue); + case "contains": + return cellValue.includes(filterValue); + case "not_contains": + return !cellValue.includes(filterValue); + default: + return true; + } + }); + }); +}; + // 집계 함수 실행 const calculateMetric = (rows: any[], field: string, aggregation: string): number => { if (rows.length === 0) return 0; @@ -33,22 +66,12 @@ const calculateMetric = (rows: any[], field: string, aggregation: string): numbe } }; -// 색상 스타일 매핑 -const colorMap = { - indigo: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - green: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - blue: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - purple: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - orange: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, - gray: { bg: "bg-muted", text: "text-foreground", border: "border-border" }, -}; - export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) { - const [metrics, setMetrics] = useState([]); - const [groupedCards, setGroupedCards] = useState>([]); + const [value, setValue] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const isGroupByMode = element?.customMetricConfig?.groupByMode || false; + + const config = element?.customMetricConfig; useEffect(() => { loadData(); @@ -64,14 +87,111 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) setLoading(true); setError(null); - // 그룹별 카드 데이터 로드 - if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) { - await loadGroupByData(); - } + const dataSourceType = element?.dataSource?.type; - // 일반 지표 데이터 로드 - if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) { - await loadMetricsData(); + // Database 타입 + if (dataSourceType === "database") { + if (!element?.dataSource?.query) { + setValue(0); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: element.dataSource.query, + connectionType: element.dataSource.connectionType || "current", + connectionId: (element.dataSource as any).connectionId, + }), + }); + + if (!response.ok) throw new Error("데이터 로딩 실패"); + + const result = await response.json(); + + if (result.success && result.data?.rows) { + let rows = result.data.rows; + + // 필터 적용 + if (config?.filters && config.filters.length > 0) { + rows = applyFilters(rows, config.filters); + } + + // 집계 계산 + if (config?.valueColumn && config?.aggregation) { + const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); + setValue(calculatedValue); + } else { + setValue(0); + } + } else { + throw new Error(result.message || "데이터 로드 실패"); + } + } + // API 타입 + else if (dataSourceType === "api") { + if (!element?.dataSource?.endpoint) { + setValue(0); + return; + } + + const token = localStorage.getItem("authToken"); + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + method: (element.dataSource as any).method || "GET", + url: element.dataSource.endpoint, + headers: (element.dataSource as any).headers || {}, + body: (element.dataSource as any).body, + authType: (element.dataSource as any).authType, + authConfig: (element.dataSource as any).authConfig, + }), + }); + + if (!response.ok) throw new Error("API 호출 실패"); + + const result = await response.json(); + + if (result.success && result.data) { + let rows: any[] = []; + + // API 응답 데이터 구조 확인 및 처리 + if (Array.isArray(result.data)) { + rows = result.data; + } else if (result.data.results && Array.isArray(result.data.results)) { + rows = result.data.results; + } else if (result.data.items && Array.isArray(result.data.items)) { + rows = result.data.items; + } else if (result.data.data && Array.isArray(result.data.data)) { + rows = result.data.data; + } else { + rows = [result.data]; + } + + // 필터 적용 + if (config?.filters && config.filters.length > 0) { + rows = applyFilters(rows, config.filters); + } + + // 집계 계산 + if (config?.valueColumn && config?.aggregation) { + const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation); + setValue(calculatedValue); + } else { + setValue(0); + } + } else { + throw new Error("API 응답 형식 오류"); + } } } catch (err) { console.error("데이터 로드 실패:", err); @@ -81,221 +201,6 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) } }; - // 그룹별 카드 데이터 로드 - const loadGroupByData = async () => { - const groupByDS = element?.customMetricConfig?.groupByDataSource; - if (!groupByDS) return; - - const dataSourceType = groupByDS.type; - - // Database 타입 - if (dataSourceType === "database") { - if (!groupByDS.query) return; - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: groupByDS.query, - connectionType: groupByDS.connectionType || "current", - connectionId: (groupByDS as any).connectionId, - }), - }); - - if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패"); - - const result = await response.json(); - - if (result.success && result.data?.rows) { - const rows = result.data.rows; - if (rows.length > 0) { - const columns = result.data.columns || Object.keys(rows[0]); - const labelColumn = columns[0]; - const valueColumn = columns[1]; - - const cards = rows.map((row: any) => ({ - label: String(row[labelColumn] || ""), - value: parseFloat(row[valueColumn]) || 0, - })); - - setGroupedCards(cards); - } - } - } - // API 타입 - else if (dataSourceType === "api") { - if (!groupByDS.endpoint) return; - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - method: (groupByDS as any).method || "GET", - url: groupByDS.endpoint, - headers: (groupByDS as any).headers || {}, - body: (groupByDS as any).body, - authType: (groupByDS as any).authType, - authConfig: (groupByDS as any).authConfig, - }), - }); - - if (!response.ok) throw new Error("그룹별 카드 API 호출 실패"); - - const result = await response.json(); - - if (result.success && result.data) { - let rows: any[] = []; - if (Array.isArray(result.data)) { - rows = result.data; - } else if (result.data.results && Array.isArray(result.data.results)) { - rows = result.data.results; - } else if (result.data.items && Array.isArray(result.data.items)) { - rows = result.data.items; - } else if (result.data.data && Array.isArray(result.data.data)) { - rows = result.data.data; - } else { - rows = [result.data]; - } - - if (rows.length > 0) { - const columns = Object.keys(rows[0]); - const labelColumn = columns[0]; - const valueColumn = columns[1]; - - const cards = rows.map((row: any) => ({ - label: String(row[labelColumn] || ""), - value: parseFloat(row[valueColumn]) || 0, - })); - - setGroupedCards(cards); - } - } - } - }; - - // 일반 지표 데이터 로드 - const loadMetricsData = async () => { - const dataSourceType = element?.dataSource?.type; - - // Database 타입 - if (dataSourceType === "database") { - if (!element?.dataSource?.query) { - setMetrics([]); - return; - } - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - query: element.dataSource.query, - connectionType: element.dataSource.connectionType || "current", - connectionId: (element.dataSource as any).connectionId, - }), - }); - - if (!response.ok) throw new Error("데이터 로딩 실패"); - - const result = await response.json(); - - if (result.success && result.data?.rows) { - const rows = result.data.rows; - - const calculatedMetrics = - element.customMetricConfig?.metrics.map((metric) => { - const value = calculateMetric(rows, metric.field, metric.aggregation); - return { - ...metric, - calculatedValue: value, - }; - }) || []; - - setMetrics(calculatedMetrics); - } else { - throw new Error(result.message || "데이터 로드 실패"); - } - } - // API 타입 - else if (dataSourceType === "api") { - if (!element?.dataSource?.endpoint) { - setMetrics([]); - return; - } - - const token = localStorage.getItem("authToken"); - const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - method: (element.dataSource as any).method || "GET", - url: element.dataSource.endpoint, - headers: (element.dataSource as any).headers || {}, - body: (element.dataSource as any).body, - authType: (element.dataSource as any).authType, - authConfig: (element.dataSource as any).authConfig, - }), - }); - - if (!response.ok) throw new Error("API 호출 실패"); - - const result = await response.json(); - - if (result.success && result.data) { - // API 응답 데이터 구조 확인 및 처리 - let rows: any[] = []; - - // result.data가 배열인 경우 - if (Array.isArray(result.data)) { - rows = result.data; - } - // result.data.results가 배열인 경우 (일반적인 API 응답 구조) - else if (result.data.results && Array.isArray(result.data.results)) { - rows = result.data.results; - } - // result.data.items가 배열인 경우 - else if (result.data.items && Array.isArray(result.data.items)) { - rows = result.data.items; - } - // result.data.data가 배열인 경우 - else if (result.data.data && Array.isArray(result.data.data)) { - rows = result.data.data; - } - // 그 외의 경우 단일 객체를 배열로 래핑 - else { - rows = [result.data]; - } - - const calculatedMetrics = - element.customMetricConfig?.metrics.map((metric) => { - const value = calculateMetric(rows, metric.field, metric.aggregation); - return { - ...metric, - calculatedValue: value, - }; - }) || []; - - setMetrics(calculatedMetrics); - } else { - throw new Error("API 응답 형식 오류"); - } - } - }; - if (loading) { return (
@@ -323,103 +228,64 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) ); } - // 데이터 소스 체크 - const hasMetricsDataSource = + // 설정 체크 + const hasDataSource = (element?.dataSource?.type === "database" && element?.dataSource?.query) || (element?.dataSource?.type === "api" && element?.dataSource?.endpoint); - const hasGroupByDataSource = - isGroupByMode && - element?.customMetricConfig?.groupByDataSource && - ((element.customMetricConfig.groupByDataSource.type === "database" && - element.customMetricConfig.groupByDataSource.query) || - (element.customMetricConfig.groupByDataSource.type === "api" && - element.customMetricConfig.groupByDataSource.endpoint)); + const hasConfig = config?.valueColumn && config?.aggregation; - const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0; - - // 둘 다 없으면 빈 화면 표시 - const shouldShowEmpty = - (!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource); - - if (shouldShowEmpty) { + // 설정이 없으면 안내 화면 + if (!hasDataSource || !hasConfig) { return (
-

사용자 커스텀 카드

+

통계 카드

-

📊 맞춤형 지표 위젯

+

📊 단일 통계 위젯

    -
  • • SQL 쿼리로 데이터를 불러옵니다
  • -
  • • 선택한 컬럼의 데이터로 지표를 계산합니다
  • -
  • • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
  • -
  • • 사용자 정의 단위 설정 가능
  • -
  • - • 그룹별 카드 생성 모드로 간편하게 사용 가능 -
  • +
  • • 데이터 소스에서 쿼리를 실행합니다
  • +
  • • 필터 조건으로 데이터를 필터링합니다
  • +
  • • 선택한 컬럼에 집계 함수를 적용합니다
  • +
  • • COUNT, SUM, AVG, MIN, MAX 지원

⚙️ 설정 방법

-

- {isGroupByMode - ? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)" - : "SQL 쿼리를 입력하고 지표를 추가하세요"} -

- {isGroupByMode &&

💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값

} +

1. 데이터 탭에서 쿼리 실행

+

2. 필터 조건 추가 (선택사항)

+

3. 계산 컬럼 및 방식 선택

+

4. 제목 및 단위 입력

); } - // 위젯 높이에 따라 레이아웃 결정 (세로 1칸이면 가로, 2칸 이상이면 세로) - // 실제 측정된 1칸 높이: 119px - const isHorizontalLayout = element?.size?.height && element.size.height <= 130; // 1칸 여유 (119px + 약간의 마진) + // 소수점 자릿수 (기본: 0) + const decimals = config?.decimals ?? 0; + const formattedValue = value.toFixed(decimals); + // 통계 카드 렌더링 return ( -
- {/* 그룹별 카드 (활성화 시) */} - {isGroupByMode && - groupedCards.map((card, index) => { - // 색상 순환 (6가지 색상) - const colorKeys = Object.keys(colorMap) as Array; - const colorKey = colorKeys[index % colorKeys.length]; - const colors = colorMap[colorKey]; +
+
+ {/* 제목 */} +
{config?.title || "통계"}
- return ( -
-
{card.label}
-
{card.value.toLocaleString()}
-
- ); - })} + {/* 값 */} +
+ {formattedValue} + {config?.unit && {config.unit}} +
- {/* 일반 지표 카드 (항상 표시) */} - {metrics.map((metric) => { - const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; - const formattedValue = metric.calculatedValue.toFixed(metric.decimals); - - return ( -
-
{metric.label}
-
- {formattedValue} - {metric.unit} -
+ {/* 필터 표시 (디버깅용, 작게) */} + {config?.filters && config.filters.length > 0 && ( +
+ 필터: {config.filters.length}개 적용됨
- ); - })} + )} +
); } diff --git a/frontend/components/dataflow/node-editor/FlowToolbar.tsx b/frontend/components/dataflow/node-editor/FlowToolbar.tsx index f266218a..d837d355 100644 --- a/frontend/components/dataflow/node-editor/FlowToolbar.tsx +++ b/frontend/components/dataflow/node-editor/FlowToolbar.tsx @@ -116,6 +116,11 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) { setFlowName(e.target.value)} + onKeyDown={(e) => { + // 입력 필드에서 키 이벤트가 FlowEditor로 전파되지 않도록 방지 + // FlowEditor의 Backspace/Delete 키로 노드가 삭제되는 것을 막음 + e.stopPropagation(); + }} className="h-8 w-[200px] text-sm" placeholder="플로우 이름" /> diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index b8f32629..1ad00044 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -413,7 +413,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { isMobile ? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40" : "relative top-0 z-auto translate-x-0" - } flex h-[calc(100vh-3.5rem)] w-[240px] max-w-[240px] min-w-[240px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`} + } flex h-[calc(100vh-3.5rem)] w-[200px] flex-col border-r border-slate-200 bg-white transition-transform duration-300`} > {/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */} {((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" || diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx new file mode 100644 index 00000000..8f05e2e3 --- /dev/null +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -0,0 +1,152 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { CodePartType, DATE_FORMAT_OPTIONS } from "@/types/numbering-rule"; + +interface AutoConfigPanelProps { + partType: CodePartType; + config?: any; + onChange: (config: any) => void; + isPreview?: boolean; +} + +export const AutoConfigPanel: React.FC = ({ + partType, + config = {}, + onChange, + isPreview = false, +}) => { + // 1. 순번 (자동 증가) + if (partType === "sequence") { + return ( +
+
+ + + onChange({ ...config, sequenceLength: parseInt(e.target.value) || 3 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 예: 3 → 001, 4 → 0001 +

+
+
+ + + onChange({ ...config, startFrom: parseInt(e.target.value) || 1 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 순번이 시작될 번호 +

+
+
+ ); + } + + // 2. 숫자 (고정 자릿수) + if (partType === "number") { + return ( +
+
+ + + onChange({ ...config, numberLength: parseInt(e.target.value) || 4 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 예: 4 → 0001, 5 → 00001 +

+
+
+ + + onChange({ ...config, numberValue: parseInt(e.target.value) || 0 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 고정으로 사용할 숫자 +

+
+
+ ); + } + + // 3. 날짜 + if (partType === "date") { + return ( +
+ + +

+ 현재 날짜가 자동으로 입력됩니다 +

+
+ ); + } + + // 4. 문자 + if (partType === "text") { + return ( +
+ + onChange({ ...config, textValue: e.target.value })} + placeholder="예: PRJ, CODE, PROD" + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 고정으로 사용할 텍스트 또는 코드 +

+
+ ); + } + + return null; +}; diff --git a/frontend/components/numbering-rule/ManualConfigPanel.tsx b/frontend/components/numbering-rule/ManualConfigPanel.tsx new file mode 100644 index 00000000..636b7914 --- /dev/null +++ b/frontend/components/numbering-rule/ManualConfigPanel.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +interface ManualConfigPanelProps { + config?: { + value?: string; + placeholder?: string; + }; + onChange: (config: any) => void; + isPreview?: boolean; +} + +export const ManualConfigPanel: React.FC = ({ + config = {}, + onChange, + isPreview = false, +}) => { + return ( +
+
+ + onChange({ ...config, value: e.target.value })} + placeholder={config.placeholder || "값을 입력하세요"} + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 코드 생성 시 이 값이 그대로 사용됩니다 +

+
+
+ + onChange({ ...config, placeholder: e.target.value })} + placeholder="예: 부서코드 입력" + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ ); +}; diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx new file mode 100644 index 00000000..83fcd3a2 --- /dev/null +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React from "react"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Trash2 } from "lucide-react"; +import { NumberingRulePart, CodePartType, GenerationMethod, CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule"; +import { AutoConfigPanel } from "./AutoConfigPanel"; +import { ManualConfigPanel } from "./ManualConfigPanel"; + +interface NumberingRuleCardProps { + part: NumberingRulePart; + onUpdate: (updates: Partial) => void; + onDelete: () => void; + isPreview?: boolean; +} + +export const NumberingRuleCard: React.FC = ({ + part, + onUpdate, + onDelete, + isPreview = false, +}) => { + return ( + + +
+ + 규칙 {part.order} + + +
+
+ + +
+ + +
+ +
+ + +
+ + {part.generationMethod === "auto" ? ( + onUpdate({ autoConfig })} + isPreview={isPreview} + /> + ) : ( + onUpdate({ manualConfig })} + isPreview={isPreview} + /> + )} +
+
+ ); +}; diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx new file mode 100644 index 00000000..96c88201 --- /dev/null +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -0,0 +1,433 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Plus, Save, Edit2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule"; +import { NumberingRuleCard } from "./NumberingRuleCard"; +import { NumberingRulePreview } from "./NumberingRulePreview"; +import { + getNumberingRules, + createNumberingRule, + updateNumberingRule, + deleteNumberingRule, +} from "@/lib/api/numberingRule"; + +interface NumberingRuleDesignerProps { + initialConfig?: NumberingRuleConfig; + onSave?: (config: NumberingRuleConfig) => void; + onChange?: (config: NumberingRuleConfig) => void; + maxRules?: number; + isPreview?: boolean; + className?: string; +} + +export const NumberingRuleDesigner: React.FC = ({ + initialConfig, + onSave, + onChange, + maxRules = 6, + isPreview = false, + className = "", +}) => { + const [savedRules, setSavedRules] = useState([]); + const [selectedRuleId, setSelectedRuleId] = useState(null); + const [currentRule, setCurrentRule] = useState(null); + const [loading, setLoading] = useState(false); + const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록"); + const [rightTitle, setRightTitle] = useState("규칙 편집"); + const [editingLeftTitle, setEditingLeftTitle] = useState(false); + const [editingRightTitle, setEditingRightTitle] = useState(false); + + useEffect(() => { + loadRules(); + }, []); + + const loadRules = useCallback(async () => { + setLoading(true); + try { + const response = await getNumberingRules(); + if (response.success && response.data) { + setSavedRules(response.data); + } else { + toast.error(response.error || "규칙 목록을 불러올 수 없습니다"); + } + } catch (error: any) { + toast.error(`로딩 실패: ${error.message}`); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (currentRule) { + onChange?.(currentRule); + } + }, [currentRule, onChange]); + + const handleAddPart = useCallback(() => { + if (!currentRule) return; + + if (currentRule.parts.length >= maxRules) { + toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`); + return; + } + + const newPart: NumberingRulePart = { + id: `part-${Date.now()}`, + order: currentRule.parts.length + 1, + partType: "text", + generationMethod: "auto", + autoConfig: { textValue: "CODE" }, + }; + + setCurrentRule((prev) => { + if (!prev) return null; + return { ...prev, parts: [...prev.parts, newPart] }; + }); + + toast.success(`규칙 ${newPart.order}가 추가되었습니다`); + }, [currentRule, maxRules]); + + const handleUpdatePart = useCallback((partId: string, updates: Partial) => { + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)), + }; + }); + }, []); + + const handleDeletePart = useCallback((partId: string) => { + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts + .filter((part) => part.id !== partId) + .map((part, index) => ({ ...part, order: index + 1 })), + }; + }); + + toast.success("규칙이 삭제되었습니다"); + }, []); + + const handleSave = useCallback(async () => { + if (!currentRule) { + toast.error("저장할 규칙이 없습니다"); + return; + } + + if (currentRule.parts.length === 0) { + toast.error("최소 1개 이상의 규칙을 추가해주세요"); + return; + } + + setLoading(true); + try { + const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId); + + let response; + if (existing) { + response = await updateNumberingRule(currentRule.ruleId, currentRule); + } else { + response = await createNumberingRule(currentRule); + } + + if (response.success && response.data) { + setSavedRules((prev) => { + if (existing) { + return prev.map((r) => (r.ruleId === currentRule.ruleId ? response.data! : r)); + } else { + return [...prev, response.data!]; + } + }); + + setCurrentRule(response.data); + setSelectedRuleId(response.data.ruleId); + + await onSave?.(response.data); + toast.success("채번 규칙이 저장되었습니다"); + } else { + toast.error(response.error || "저장 실패"); + } + } catch (error: any) { + toast.error(`저장 실패: ${error.message}`); + } finally { + setLoading(false); + } + }, [currentRule, savedRules, onSave]); + + const handleSelectRule = useCallback((rule: NumberingRuleConfig) => { + setSelectedRuleId(rule.ruleId); + setCurrentRule(rule); + toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`); + }, []); + + const handleDeleteSavedRule = useCallback(async (ruleId: string) => { + setLoading(true); + try { + const response = await deleteNumberingRule(ruleId); + + if (response.success) { + setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId)); + + if (selectedRuleId === ruleId) { + setSelectedRuleId(null); + setCurrentRule(null); + } + + toast.success("규칙이 삭제되었습니다"); + } else { + toast.error(response.error || "삭제 실패"); + } + } catch (error: any) { + toast.error(`삭제 실패: ${error.message}`); + } finally { + setLoading(false); + } + }, [selectedRuleId]); + + const handleNewRule = useCallback(() => { + const newRule: NumberingRuleConfig = { + ruleId: `rule-${Date.now()}`, + ruleName: "새 채번 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + scopeType: "global", + }; + + setSelectedRuleId(newRule.ruleId); + setCurrentRule(newRule); + + toast.success("새 규칙이 생성되었습니다"); + }, []); + + return ( +
+ {/* 좌측: 저장된 규칙 목록 */} +
+
+ {editingLeftTitle ? ( + setLeftTitle(e.target.value)} + onBlur={() => setEditingLeftTitle(false)} + onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)} + className="h-8 text-sm font-semibold" + autoFocus + /> + ) : ( +

{leftTitle}

+ )} + +
+ + + +
+ {loading ? ( +
+

로딩 중...

+
+ ) : savedRules.length === 0 ? ( +
+

저장된 규칙이 없습니다

+
+ ) : ( + savedRules.map((rule) => ( + handleSelectRule(rule)} + > + +
+
+ {rule.ruleName} +

+ 규칙 {rule.parts.length}개 +

+
+ +
+
+ + + +
+ )) + )} +
+
+ + {/* 구분선 */} +
+ + {/* 우측: 편집 영역 */} +
+ {!currentRule ? ( +
+
+

+ 규칙을 선택해주세요 +

+

+ 좌측에서 규칙을 선택하거나 새로 생성하세요 +

+
+
+ ) : ( + <> +
+ {editingRightTitle ? ( + setRightTitle(e.target.value)} + onBlur={() => setEditingRightTitle(false)} + onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)} + className="h-8 text-sm font-semibold" + autoFocus + /> + ) : ( +

{rightTitle}

+ )} + +
+ +
+ + + setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value })) + } + className="h-9" + placeholder="예: 프로젝트 코드" + /> +
+ +
+ + +

+ {currentRule.scopeType === "menu" + ? "이 규칙이 설정된 상위 메뉴의 모든 하위 메뉴에서 사용 가능합니다" + : "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"} +

+
+ + + + 미리보기 + + + + + + +
+
+

코드 구성

+ + {currentRule.parts.length}/{maxRules} + +
+ + {currentRule.parts.length === 0 ? ( +
+

+ 규칙을 추가하여 코드를 구성하세요 +

+
+ ) : ( +
+ {currentRule.parts.map((part) => ( + handleUpdatePart(part.id, updates)} + onDelete={() => handleDeletePart(part.id)} + isPreview={isPreview} + /> + ))} +
+ )} +
+ +
+ + +
+ + )} +
+
+ ); +}; diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx new file mode 100644 index 00000000..e29cd4f4 --- /dev/null +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -0,0 +1,91 @@ +"use client"; + +import React, { useMemo } from "react"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +interface NumberingRulePreviewProps { + config: NumberingRuleConfig; + compact?: boolean; +} + +export const NumberingRulePreview: React.FC = ({ + config, + compact = false +}) => { + const generatedCode = useMemo(() => { + if (!config.parts || config.parts.length === 0) { + return "규칙을 추가해주세요"; + } + + const parts = config.parts + .sort((a, b) => a.order - b.order) + .map((part) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + // 1. 순번 (자동 증가) + case "sequence": { + const length = autoConfig.sequenceLength || 3; + const startFrom = autoConfig.startFrom || 1; + return String(startFrom).padStart(length, "0"); + } + + // 2. 숫자 (고정 자릿수) + case "number": { + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 0; + return String(value).padStart(length, "0"); + } + + // 3. 날짜 + case "date": { + const format = autoConfig.dateFormat || "YYYYMMDD"; + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + + switch (format) { + case "YYYY": return String(year); + case "YY": return String(year).slice(-2); + case "YYYYMM": return `${year}${month}`; + case "YYMM": return `${String(year).slice(-2)}${month}`; + case "YYYYMMDD": return `${year}${month}${day}`; + case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; + default: return `${year}${month}${day}`; + } + } + + // 4. 문자 + case "text": + return autoConfig.textValue || "TEXT"; + + default: + return "XXX"; + } + }); + + return parts.join(config.separator || ""); + }, [config]); + + if (compact) { + return ( +
+ {generatedCode} +
+ ); + } + + return ( +
+

코드 미리보기

+
+ {generatedCode} +
+
+ ); +}; diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 2d3fb513..76363e4f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -102,13 +102,6 @@ export const EditModal: React.FC = ({ className }) => { useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { const { screenId, title, description, modalSize, editData, onSave } = event.detail; - console.log("🚀 EditModal 열기 이벤트 수신:", { - screenId, - title, - description, - modalSize, - editData, - }); setModalState({ isOpen: true, @@ -126,7 +119,16 @@ export const EditModal: React.FC = ({ className }) => { }; const handleCloseEditModal = () => { - console.log("🚪 EditModal 닫기 이벤트 수신"); + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("⚠️ onSave 콜백 에러:", callbackError); + } + } + + // 모달 닫기 handleClose(); }; @@ -137,7 +139,7 @@ export const EditModal: React.FC = ({ className }) => { window.removeEventListener("openEditModal", handleOpenEditModal as EventListener); window.removeEventListener("closeEditModal", handleCloseEditModal); }; - }, []); + }, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조 // 화면 데이터 로딩 useEffect(() => { @@ -211,12 +213,6 @@ export const EditModal: React.FC = ({ className }) => { } try { - console.log("💾 수정 저장 시작:", { - tableName: screenData.screenInfo.tableName, - formData, - originalData, - }); - // 변경된 필드만 추출 const changedData: Record = {}; Object.keys(formData).forEach((key) => { @@ -225,26 +221,33 @@ export const EditModal: React.FC = ({ className }) => { } }); - console.log("📝 변경된 필드:", changedData); - if (Object.keys(changedData).length === 0) { toast.info("변경된 내용이 없습니다."); handleClose(); return; } + // 기본키 확인 (id 또는 첫 번째 키) + const recordId = originalData.id || Object.values(originalData)[0]; + // UPDATE 액션 실행 - const response = await dynamicFormApi.updateData(screenData.screenInfo.tableName, { - ...originalData, // 원본 데이터 (WHERE 조건용) - ...changedData, // 변경된 데이터만 - }); + const response = await dynamicFormApi.updateFormDataPartial( + recordId, + originalData, + changedData, + screenData.screenInfo.tableName, + ); if (response.success) { toast.success("데이터가 수정되었습니다."); - // 부모 컴포넌트의 onSave 콜백 실행 + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) if (modalState.onSave) { - modalState.onSave(); + try { + modalState.onSave(); + } catch (callbackError) { + console.error("⚠️ onSave 콜백 에러:", callbackError); + } } handleClose(); @@ -335,16 +338,10 @@ export const EditModal: React.FC = ({ className }) => { allComponents={screenData.components} formData={formData} onFormDataChange={(fieldName, value) => { - console.log(`🎯 EditModal onFormDataChange 호출: ${fieldName} = "${value}"`); - console.log("📋 현재 formData:", formData); - setFormData((prev) => { - const newFormData = { - ...prev, - [fieldName]: value, - }; - console.log("📝 EditModal 업데이트된 formData:", newFormData); - return newFormData; - }); + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); }} screenInfo={{ id: modalState.screenId!, diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index b54df6ad..f4123169 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -38,10 +38,12 @@ import { Folder, FolderOpen, Grid, + Filter, } from "lucide-react"; import { tableTypeApi } from "@/lib/api/screen"; import { commonCodeApi } from "@/lib/api/commonCode"; import { getCurrentUser, UserInfo } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup"; import { cn } from "@/lib/utils"; import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file"; @@ -99,6 +101,7 @@ export const InteractiveDataTable: React.FC = ({ onRefresh, }) => { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + const { user } = useAuth(); // 사용자 정보 가져오기 const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [searchValues, setSearchValues] = useState>({}); @@ -106,6 +109,10 @@ export const InteractiveDataTable: React.FC = ({ const [totalPages, setTotalPages] = useState(1); const [total, setTotal] = useState(0); const [selectedRows, setSelectedRows] = useState>(new Set()); + const [columnWidths, setColumnWidths] = useState>({}); + const hasInitializedWidthsRef = useRef(false); + const columnRefs = useRef>({}); + const isResizingRef = useRef(false); // SaveModal 상태 (등록/수정 통합) const [showSaveModal, setShowSaveModal] = useState(false); @@ -130,6 +137,13 @@ export const InteractiveDataTable: React.FC = ({ // 공통코드 관리 상태 const [codeOptions, setCodeOptions] = useState>>({}); + // 🆕 검색 필터 관련 상태 (FlowWidget과 동일) + const [searchFilterColumns, setSearchFilterColumns] = useState>(new Set()); // 검색 필터로 사용할 컬럼 + const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그 + const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 + const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 + const [columnLabels, setColumnLabels] = useState>({}); // 컬럼명 -> 라벨 매핑 + // 공통코드 옵션 가져오기 const loadCodeOptions = useCallback( async (categoryCode: string) => { @@ -408,6 +422,35 @@ export const InteractiveDataTable: React.FC = ({ // 페이지 크기 설정 const pageSize = component.pagination?.pageSize || 10; + // 초기 컬럼 너비 측정 (한 번만) + useEffect(() => { + if (!hasInitializedWidthsRef.current && visibleColumns.length > 0) { + // 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정 + const timer = setTimeout(() => { + const newWidths: Record = {}; + let hasAnyWidth = false; + + visibleColumns.forEach((column) => { + const thElement = columnRefs.current[column.id]; + if (thElement) { + const measuredWidth = thElement.offsetWidth; + if (measuredWidth > 0) { + newWidths[column.id] = measuredWidth; + hasAnyWidth = true; + } + } + }); + + if (hasAnyWidth) { + setColumnWidths(newWidths); + hasInitializedWidthsRef.current = true; + } + }, 100); + + return () => clearTimeout(timer); + } + }, [visibleColumns]); + // 데이터 로드 함수 const loadData = useCallback( async (page: number = 1, searchParams: Record = {}) => { @@ -442,6 +485,7 @@ export const InteractiveDataTable: React.FC = ({ page, size: pageSize, search: searchParams, + autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달 }); setData(result.data); @@ -533,7 +577,7 @@ export const InteractiveDataTable: React.FC = ({ setLoading(false); } }, - [component.tableName, pageSize], + [component.tableName, pageSize, component.autoFilter], // 🆕 autoFilter 추가 ); // 현재 사용자 정보 로드 @@ -600,6 +644,31 @@ export const InteractiveDataTable: React.FC = ({ try { const columns = await tableTypeApi.getColumns(component.tableName); setTableColumns(columns); + + // 🆕 전체 컬럼 목록 설정 + const columnNames = columns.map(col => col.columnName); + setAllAvailableColumns(columnNames); + + // 🆕 컬럼명 -> 라벨 매핑 생성 + const labels: Record = {}; + columns.forEach(col => { + labels[col.columnName] = col.displayName || col.columnName; + }); + setColumnLabels(labels); + + // 🆕 localStorage에서 필터 설정 복원 + if (user?.userId && component.componentId) { + const storageKey = `table-search-filter-${user.userId}-${component.componentId}`; + const savedFilter = localStorage.getItem(storageKey); + if (savedFilter) { + try { + const parsed = JSON.parse(savedFilter); + setSearchFilterColumns(new Set(parsed)); + } catch (e) { + console.error("필터 설정 복원 실패:", e); + } + } + } } catch (error) { // console.error("테이블 컬럼 정보 로드 실패:", error); } @@ -608,7 +677,7 @@ export const InteractiveDataTable: React.FC = ({ if (component.tableName) { fetchTableColumns(); } - }, [component.tableName]); + }, [component.tableName, component.componentId, user?.userId]); // 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함) const searchFilters = useMemo(() => { @@ -769,7 +838,7 @@ export const InteractiveDataTable: React.FC = ({ setShowSaveModal(true); }, [getDisplayColumns, generateAutoValue, component.addModalConfig]); - // 데이터 수정 핸들러 (SaveModal 사용) + // 데이터 수정 핸들러 (EditModal 사용) const handleEditData = useCallback(() => { if (selectedRows.size !== 1) return; @@ -793,17 +862,25 @@ export const InteractiveDataTable: React.FC = ({ initialData[col.columnName] = selectedRowData[col.columnName] || ""; }); - setEditFormData(initialData); - setEditingRowData(selectedRowData); - // 수정 모달 설정에서 제목과 설명 가져오기 - const editModalTitle = component.editModalConfig?.title || ""; + const editModalTitle = component.editModalConfig?.title || "데이터 수정"; const editModalDescription = component.editModalConfig?.description || ""; - console.log("📝 수정 모달 설정:", { editModalTitle, editModalDescription }); - - setShowEditModal(true); - }, [selectedRows, data, getDisplayColumns, component.editModalConfig]); + // 전역 EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId, + title: editModalTitle, + description: editModalDescription, + modalSize: "lg", + editData: initialData, + onSave: () => { + loadData(); // 테이블 데이터 새로고침 + }, + }, + }); + window.dispatchEvent(event); + }, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData]); // 수정 폼 데이터 변경 핸들러 const handleEditFormChange = useCallback((columnName: string, value: any) => { @@ -1011,6 +1088,29 @@ export const InteractiveDataTable: React.FC = ({ } }, [isAdding]); + // 🆕 검색 필터 저장 함수 + const handleSaveSearchFilter = useCallback(() => { + if (user?.userId && component.componentId) { + const storageKey = `table-search-filter-${user.userId}-${component.componentId}`; + const filterArray = Array.from(searchFilterColumns); + localStorage.setItem(storageKey, JSON.stringify(filterArray)); + toast.success("검색 필터 설정이 저장되었습니다."); + } + }, [user?.userId, component.componentId, searchFilterColumns]); + + // 🆕 검색 필터 토글 함수 + const handleToggleFilterColumn = useCallback((columnName: string) => { + setSearchFilterColumns((prev) => { + const newSet = new Set(prev); + if (newSet.has(columnName)) { + newSet.delete(columnName); + } else { + newSet.add(columnName); + } + return newSet; + }); + }, []); + // 데이터 삭제 핸들러 const handleDeleteData = useCallback(() => { if (selectedRows.size === 0) { @@ -1767,8 +1867,11 @@ export const InteractiveDataTable: React.FC = ({ case "number": case "decimal": - if (typeof value === "number") { - return value.toLocaleString(); + if (value !== null && value !== undefined && value !== "") { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue)) { + return numValue.toLocaleString("ko-KR"); + } } break; @@ -1909,27 +2012,97 @@ export const InteractiveDataTable: React.FC = ({ {visibleColumns.length > 0 ? ( <>
- - +
+ {/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */} {component.enableDelete && ( - + 0} onCheckedChange={handleSelectAll} /> )} - {visibleColumns.map((column: DataTableColumn) => ( - - {column.label} - - ))} + {visibleColumns.map((column: DataTableColumn, columnIndex) => { + const columnWidth = columnWidths[column.id]; + + return ( + (columnRefs.current[column.id] = el)} + className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors" + style={{ + width: columnWidth ? `${columnWidth}px` : undefined, + userSelect: 'none' + }} + > + {column.label} + {/* 리사이즈 핸들 */} + {columnIndex < visibleColumns.length - 1 && ( +
e.stopPropagation()} + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + + const thElement = columnRefs.current[column.id]; + if (!thElement) return; + + isResizingRef.current = true; + + const startX = e.clientX; + const startWidth = columnWidth || thElement.offsetWidth; + + // 드래그 중 텍스트 선택 방지 + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const handleMouseMove = (moveEvent: MouseEvent) => { + moveEvent.preventDefault(); + + const diff = moveEvent.clientX - startX; + const newWidth = Math.max(80, startWidth + diff); + + // 직접 DOM 스타일 변경 (리렌더링 없음) + if (thElement) { + thElement.style.width = `${newWidth}px`; + } + }; + + const handleMouseUp = () => { + // 최종 너비를 state에 저장 + if (thElement) { + const finalWidth = Math.max(80, thElement.offsetWidth); + setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth })); + } + + // 텍스트 선택 복원 + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + + // 약간의 지연 후 리사이즈 플래그 해제 + setTimeout(() => { + isResizingRef.current = false; + }, 100); + + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }} + /> + )} + + ); + })} {/* 자동 파일 컬럼 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */} @@ -1951,18 +2124,28 @@ export const InteractiveDataTable: React.FC = ({ {/* 체크박스 셀 (삭제 기능이 활성화된 경우) */} {component.enableDelete && ( - + handleRowSelect(rowIndex, checked as boolean)} /> )} - {visibleColumns.map((column: DataTableColumn) => ( - - {formatCellValue(row[column.columnName], column, row)} - - ))} + {visibleColumns.map((column: DataTableColumn) => { + const isNumeric = column.widgetType === "number" || column.widgetType === "decimal"; + return ( + + {formatCellValue(row[column.columnName], column, row)} + + ); + })} {/* 자동 파일 셀 표시 제거됨 - 명시적으로 추가된 파일 컬럼만 표시 */} )) diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 8a114aa4..4b292541 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -36,7 +36,7 @@ import { InteractiveDataTable } from "./InteractiveDataTable"; import { FileUpload } from "./widgets/FileUpload"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { useParams } from "next/navigation"; -import { screenApi } from "@/lib/api/screen"; +import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer"; import { enhancedFormService } from "@/lib/services/enhancedFormService"; import { FormValidationIndicator } from "@/components/common/FormValidationIndicator"; @@ -237,14 +237,46 @@ export const InteractiveScreenViewer: React.FC = ( // 자동입력 필드들의 값을 formData에 초기 설정 React.useEffect(() => { // console.log("🚀 자동입력 초기화 useEffect 실행 - allComponents 개수:", allComponents.length); - const initAutoInputFields = () => { + const initAutoInputFields = async () => { // console.log("🔧 initAutoInputFields 실행 시작"); - allComponents.forEach(comp => { - if (comp.type === 'widget') { + for (const comp of allComponents) { + // 🆕 type: "component" 또는 type: "widget" 모두 처리 + if (comp.type === 'widget' || comp.type === 'component') { const widget = comp as WidgetComponent; const fieldName = widget.columnName || widget.id; - // 텍스트 타입 위젯의 자동입력 처리 + // 🆕 autoFill 처리 (테이블 조회 기반 자동 입력) + if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { + const autoFillConfig = widget.autoFill || (comp as any).autoFill; + const currentValue = formData[fieldName]; + if (currentValue === undefined || currentValue === '') { + const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; + + // 사용자 정보에서 필터 값 가져오기 + const userValue = user?.[userField]; + + if (userValue && sourceTable && filterColumn && displayColumn) { + try { + const result = await tableTypeApi.getTableRecord( + sourceTable, + filterColumn, + userValue, + displayColumn + ); + + updateFormData(fieldName, result.value); + } catch (error) { + console.error(`autoFill 조회 실패: ${fieldName}`, error); + } + } + } + continue; // autoFill이 활성화되면 일반 자동입력은 건너뜀 + } + + // 기존 widget 타입 전용 로직은 widget인 경우만 + if (comp.type !== 'widget') continue; + + // 텍스트 타입 위젯의 자동입력 처리 (기존 로직) if ((widget.widgetType === 'text' || widget.widgetType === 'email' || widget.widgetType === 'tel') && widget.webTypeConfig) { const config = widget.webTypeConfig as TextTypeConfig; @@ -278,12 +310,12 @@ export const InteractiveScreenViewer: React.FC = ( } } } - }); + } }; // 초기 로드 시 자동입력 필드들 설정 initAutoInputFields(); - }, [allComponents, generateAutoValue]); // formData는 의존성에서 제외 (무한 루프 방지) + }, [allComponents, generateAutoValue, user]); // formData는 의존성에서 제외 (무한 루프 방지) // 날짜 값 업데이트 const updateDateValue = (fieldName: string, date: Date | undefined) => { @@ -1221,6 +1253,12 @@ export const InteractiveScreenViewer: React.FC = ( const handleSaveAction = async () => { // console.log("💾 저장 시작"); + // ✅ 사용자 정보가 로드되지 않았으면 저장 불가 + if (!user?.userId) { + alert("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요."); + return; + } + // 개선된 검증 시스템이 활성화된 경우 if (enhancedValidation) { // console.log("🔍 개선된 검증 시스템 사용"); @@ -1357,19 +1395,26 @@ export const InteractiveScreenViewer: React.FC = ( allComponents.find(c => c.columnName)?.tableName || "dynamic_form_data"; // 기본값 - // 🆕 자동으로 작성자 정보 추가 - const writerValue = user?.userId || userName || "unknown"; + // 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음) + const writerValue = user.userId; + const companyCodeValue = user.companyCode || ""; + console.log("👤 현재 사용자 정보:", { - userId: user?.userId, + userId: user.userId, userName: userName, - writerValue: writerValue, + companyCode: user.companyCode, // ✅ 회사 코드 + formDataWriter: mappedData.writer, // ✅ 폼에서 입력한 writer 값 + formDataCompanyCode: mappedData.company_code, // ✅ 폼에서 입력한 company_code 값 + defaultWriterValue: writerValue, + companyCodeValue, // ✅ 최종 회사 코드 값 }); const dataWithUserInfo = { ...mappedData, - writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼 - created_by: writerValue, - updated_by: writerValue, + writer: mappedData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId + created_by: writerValue, // created_by는 항상 로그인한 사람 + updated_by: writerValue, // updated_by는 항상 로그인한 사람 + company_code: mappedData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode }; const saveData: DynamicFormData = { @@ -1633,24 +1678,19 @@ export const InteractiveScreenViewer: React.FC = ( } }; - return ( + return applyStyles(
{/* 상세 설정 영역 */} -
{renderWebTypeConfig(widget)}
+
+
+ {console.log("🔍 [DetailSettingsPanel] widget 타입:", selectedComponent?.type, "autoFill:", widget.autoFill)} + {/* 🆕 자동 입력 섹션 */} +
+

+ + 🔥 테이블 데이터 자동 입력 (테스트) +

+
+
+ { + onUpdateProperty(widget.id, "autoFill", { + enabled: checked as boolean, + sourceTable: widget.autoFill?.sourceTable || '', + filterColumn: widget.autoFill?.filterColumn || 'company_code', + userField: widget.autoFill?.userField || 'companyCode', + displayColumn: widget.autoFill?.displayColumn || '', + }); + }} + /> + +
+ + {widget.autoFill?.enabled && ( +
+
+ + +

+ 데이터를 조회할 테이블 +

+
+ +
+ + { + onUpdateProperty(widget.id, "autoFill", { + ...widget.autoFill!, + filterColumn: e.target.value, + }); + }} + placeholder="company_code" + className="text-xs" + /> +

+ 예: company_code, dept_code, user_id +

+
+ +
+ + +

+ 로그인한 사용자 정보에서 가져올 필드 +

+
+ +
+ + { + onUpdateProperty(widget.id, "autoFill", { + ...widget.autoFill!, + displayColumn: e.target.value, + }); + }} + placeholder="company_name" + className="text-xs" + /> +

+ Input에 표시할 컬럼명 (예: company_name, dept_name) +

+
+
+ )} +
+
+ + {/* 웹타입 설정 */} + + {renderWebTypeConfig(widget)} +
+
); }; diff --git a/frontend/components/screen/panels/FileComponentConfigPanel.tsx b/frontend/components/screen/panels/FileComponentConfigPanel.tsx index 8db01ad0..f14c861f 100644 --- a/frontend/components/screen/panels/FileComponentConfigPanel.tsx +++ b/frontend/components/screen/panels/FileComponentConfigPanel.tsx @@ -12,7 +12,7 @@ import { Plus, X, Upload, File, Image, FileText, Download, Trash2 } from "lucide import { Button } from "@/components/ui/button"; import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upload/types"; import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file"; -import { formatFileSize } from "@/lib/utils"; +import { formatFileSize, cn } from "@/lib/utils"; import { toast } from "sonner"; interface FileComponentConfigPanelProps { diff --git a/frontend/components/screen/panels/GridPanel.tsx b/frontend/components/screen/panels/GridPanel.tsx index 16178e57..a38c4cd6 100644 --- a/frontend/components/screen/panels/GridPanel.tsx +++ b/frontend/components/screen/panels/GridPanel.tsx @@ -127,10 +127,27 @@ export const GridPanel: React.FC = ({
+
+ { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 24) { + updateSetting("columns", value); + } + }} + className="h-8 text-xs" + /> + / 24 +
= ({ className="w-full" />
- 1 - 24 + 1열 + 24열
diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index b45bc517..88643c60 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -109,6 +109,12 @@ interface PropertiesPanelProps { draggedComponent: ComponentData | null; currentPosition: { x: number; y: number; z: number }; }; + gridSettings?: { + columns: number; + gap: number; + padding: number; + snapToGrid: boolean; + }; onUpdateProperty: (path: string, value: unknown) => void; onDeleteComponent: () => void; onCopyComponent: () => void; @@ -124,6 +130,7 @@ const PropertiesPanelComponent: React.FC = ({ selectedComponent, tables = [], dragState, + gridSettings, onUpdateProperty, onDeleteComponent, onCopyComponent, @@ -744,9 +751,47 @@ const PropertiesPanelComponent: React.FC = ({ {/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */} {selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? ( <> - {/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */} + {/* 🆕 그리드 컬럼 수 직접 입력 */}
- + +
+ { + const value = parseInt(e.target.value, 10); + const maxColumns = gridSettings?.columns || 12; + if (!isNaN(value) && value >= 1 && value <= maxColumns) { + // gridColumns 업데이트 + onUpdateProperty("gridColumns", value); + + // width를 퍼센트로 계산하여 업데이트 + const widthPercent = (value / maxColumns) * 100; + onUpdateProperty("style.width", `${widthPercent}%`); + + // localWidthSpan도 업데이트 + setLocalWidthSpan(calculateWidthSpan(`${widthPercent}%`, value)); + } + }} + className="h-8 text-xs" + /> + + / {gridSettings?.columns || 12}열 + +
+

+ 이 컴포넌트가 차지할 그리드 컬럼 수 (1-{gridSettings?.columns || 12}) +

+
+ + {/* 기존 컬럼 스팬 선택 (width를 퍼센트로 변환) - 참고용 */} +
+ { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1) { + updateGridSetting("columns", value); + } + }} + className="h-6 px-2 py-0 text-xs" + style={{ fontSize: "12px" }} + placeholder="1 이상의 숫자" + /> +
+

+ 1 이상의 숫자를 입력하세요 +

+ + + {/* 간격 */} +
+ + updateGridSetting("gap", value)} + className="w-full" + /> +
+ + {/* 여백 */} +
+ + updateGridSetting("padding", value)} + className="w-full" + /> +
+ + + ); + }; + + // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 if (!selectedComponent) { return (
- {/* 해상도 설정만 표시 */} + {/* 해상도 설정과 격자 설정 표시 */}
+ {/* 해상도 설정 */} {currentResolution && onResolutionChange && ( -
-
- -

해상도 설정

+ <> +
+
+ +

해상도 설정

+
+
- -
+ + )} + {/* 격자 설정 */} + {renderGridSettings()} + {/* 안내 메시지 */}
- -

컴포넌트를 선택하여

-

속성을 편집하세요

+ +

컴포넌트를 선택하여

+

속성을 편집하세요

@@ -283,22 +425,31 @@ export const UnifiedPropertiesPanel: React.FC = ({
{(selectedComponent as any).gridColumns !== undefined && (
- - + +
+ { + const value = parseInt(e.target.value, 10); + const maxColumns = gridSettings?.columns || 12; + if (!isNaN(value) && value >= 1 && value <= maxColumns) { + handleUpdate("gridColumns", value); + + // width를 퍼센트로 계산하여 업데이트 + const widthPercent = (value / maxColumns) * 100; + handleUpdate("style.width", `${widthPercent}%`); + } + }} + className="h-6 w-full px-2 py-0 text-xs" + style={{ fontSize: "12px" }} + /> + + /{gridSettings?.columns || 12} + +
)}
@@ -412,8 +563,11 @@ export const UnifiedPropertiesPanel: React.FC = ({ // 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합) const renderDetailTab = () => { + console.log("🔍 [renderDetailTab] selectedComponent.type:", selectedComponent.type); + // 1. DataTable 컴포넌트 if (selectedComponent.type === "datatable") { + console.log("✅ [renderDetailTab] DataTable 컴포넌트"); return ( = ({ // 5. 새로운 컴포넌트 시스템 (type: "component") if (selectedComponent.type === "component") { + console.log("✅ [renderDetailTab] Component 타입"); const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type; const webType = selectedComponent.componentConfig?.webType; @@ -479,7 +634,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ if (!componentId) { return (
-

컴포넌트 ID가 설정되지 않았습니다

+

컴포넌트 ID가 설정되지 않았습니다

); } @@ -511,7 +666,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
{option.label}
-
{option.description}
+
{option.description}
))} @@ -535,45 +690,154 @@ export const UnifiedPropertiesPanel: React.FC = ({ }); }} /> + + {/* 🆕 테이블 데이터 자동 입력 (component 타입용) */} + +
+
+ +

테이블 데이터 자동 입력

+
+ + {/* 활성화 체크박스 */} +
+ { + handleUpdate("autoFill", { + ...selectedComponent.autoFill, + enabled: Boolean(checked), + }); + }} + /> + +
+ + {selectedComponent.autoFill?.enabled && ( + <> + {/* 조회할 테이블 */} +
+ + +
+ + {/* 필터링할 컬럼 */} +
+ + { + handleUpdate("autoFill", { + ...selectedComponent.autoFill, + enabled: selectedComponent.autoFill?.enabled || false, + filterColumn: e.target.value, + }); + }} + placeholder="예: company_code" + className="h-6 w-full px-2 py-0 text-xs" + /> +
+ + {/* 사용자 정보 필드 */} +
+ + +
+ + {/* 표시할 컬럼 */} +
+ + { + handleUpdate("autoFill", { + ...selectedComponent.autoFill, + enabled: selectedComponent.autoFill?.enabled || false, + displayColumn: e.target.value, + }); + }} + placeholder="예: company_name" + className="h-6 w-full px-2 py-0 text-xs" + /> +
+ + )} +
); } // 6. Widget 컴포넌트 if (selectedComponent.type === "widget") { + console.log("✅ [renderDetailTab] Widget 타입"); const widget = selectedComponent as WidgetComponent; + console.log("🔍 [renderDetailTab] widget.widgetType:", widget.widgetType); - // Widget에 webType이 있는 경우 - if (widget.webType) { - return ( -
- {/* WebType 선택 */} -
- - -
-
- ); - } - - // 새로운 컴포넌트 시스템 (widgetType이 button, card 등) + // 새로운 컴포넌트 시스템 (widgetType이 button, card 등) - 먼저 체크 if ( widget.widgetType && ["button", "card", "dashboard", "stats-card", "progress-bar", "chart", "alert", "badge"].includes( widget.widgetType, ) ) { + console.log("✅ [renderDetailTab] DynamicComponent 반환 (widgetType)"); return ( = ({ /> ); } + + // 일반 위젯 (webType 기반) + console.log("✅ [renderDetailTab] 일반 위젯 렌더링 시작"); + return ( +
+ {console.log("🔍 [UnifiedPropertiesPanel] widget.webType:", widget.webType, "widget:", widget)} + {/* WebType 선택 (있는 경우만) */} + {widget.webType && ( +
+ + +
+ )} + + {/* 🆕 테이블 데이터 자동 입력 (모든 widget 컴포넌트) */} + +
+
+ +

테이블 데이터 자동 입력

+
+ + {/* 활성화 체크박스 */} +
+ { + handleUpdate("autoFill", { + ...widget.autoFill, + enabled: Boolean(checked), + }); + }} + /> + +
+ + {widget.autoFill?.enabled && ( + <> + {/* 조회할 테이블 */} +
+ + +
+ + {/* 필터링할 컬럼 */} +
+ + { + handleUpdate("autoFill", { + ...widget.autoFill, + enabled: widget.autoFill?.enabled || false, + filterColumn: e.target.value, + }); + }} + placeholder="예: company_code" + className="h-6 w-full px-2 py-0 text-xs" + /> +
+ + {/* 사용자 정보 필드 */} +
+ + +
+ + {/* 표시할 컬럼 */} +
+ + { + handleUpdate("autoFill", { + ...widget.autoFill, + enabled: widget.autoFill?.enabled || false, + displayColumn: e.target.value, + }); + }} + placeholder="예: company_name" + className="h-6 w-full px-2 py-0 text-xs" + /> +
+ + )} +
+
+ ); } // 기본 메시지 return (
-

이 컴포넌트는 추가 설정이 없습니다

+

이 컴포넌트는 추가 설정이 없습니다

); }; @@ -602,9 +1022,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ return (
{/* 헤더 - 간소화 */} -
+
{selectedComponent.type === "widget" && ( -
+
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
)} @@ -627,6 +1047,10 @@ export const UnifiedPropertiesPanel: React.FC = ({ )} + {/* 격자 설정 - 해상도 설정 아래 표시 */} + {renderGridSettings()} + {gridSettings && onGridSettingsChange && } + {/* 기본 설정 */} {renderBasicTab()} diff --git a/frontend/components/screen/templates/DataTableTemplate.tsx b/frontend/components/screen/templates/DataTableTemplate.tsx index 86422e7f..b24f27c3 100644 --- a/frontend/components/screen/templates/DataTableTemplate.tsx +++ b/frontend/components/screen/templates/DataTableTemplate.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { Table, Filter, Search, Download, RefreshCw, Plus, Edit, Trash2 } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -8,6 +8,9 @@ import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { toast } from "sonner"; +import { useAuth } from "@/hooks/useAuth"; /** * 데이터 테이블 템플릿 컴포넌트 @@ -121,6 +124,13 @@ export const DataTableTemplate: React.FC = ({ className = "", isPreview = true, }) => { + const { user } = useAuth(); + + // 🆕 검색 필터 관련 상태 + const [searchFilterColumns, setSearchFilterColumns] = useState>(new Set()); + const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); + const [searchValues, setSearchValues] = useState>({}); + // 설정된 컬럼만 사용 (자동 생성 안함) const defaultColumns = React.useMemo(() => { return columns || []; @@ -138,6 +148,54 @@ export const DataTableTemplate: React.FC = ({ }, [isPreview]); const visibleColumns = defaultColumns.filter((col) => col.visible); + + // 🆕 컬럼명 -> 라벨 매핑 + const columnLabels = React.useMemo(() => { + const labels: Record = {}; + defaultColumns.forEach(col => { + labels[col.id] = col.label; + }); + return labels; + }, [defaultColumns]); + + // 🆕 localStorage에서 필터 설정 복원 + useEffect(() => { + if (user?.userId && title) { + const storageKey = `datatable-search-filter-${user.userId}-${title}`; + const savedFilter = localStorage.getItem(storageKey); + if (savedFilter) { + try { + const parsed = JSON.parse(savedFilter); + setSearchFilterColumns(new Set(parsed)); + } catch (e) { + console.error("필터 설정 복원 실패:", e); + } + } + } + }, [user?.userId, title]); + + // 🆕 필터 저장 함수 + const handleSaveSearchFilter = useCallback(() => { + if (user?.userId && title) { + const storageKey = `datatable-search-filter-${user.userId}-${title}`; + const filterArray = Array.from(searchFilterColumns); + localStorage.setItem(storageKey, JSON.stringify(filterArray)); + toast.success("검색 필터 설정이 저장되었습니다."); + } + }, [user?.userId, title, searchFilterColumns]); + + // 🆕 필터 토글 함수 + const handleToggleFilterColumn = useCallback((columnId: string) => { + setSearchFilterColumns((prev) => { + const newSet = new Set(prev); + if (newSet.has(columnId)) { + newSet.delete(columnId); + } else { + newSet.add(columnId); + } + return newSet; + }); + }, []); return ( @@ -178,23 +236,65 @@ export const DataTableTemplate: React.FC = ({ + {/* 🆕 검색 필터 설정 버튼 영역 */} + {defaultColumns.length > 0 && ( +
+ +
+ )} + + {/* 🆕 선택된 컬럼의 검색 입력 필드 */} + {searchFilterColumns.size > 0 && ( +
+ {Array.from(searchFilterColumns).map((columnId) => { + const column = defaultColumns.find(col => col.id === columnId); + if (!column) return null; + + return ( +
+ + setSearchValues(prev => ({...prev, [columnId]: e.target.value}))} + disabled={isPreview} + className="h-9 text-sm" + /> +
+ ); + })} +
+ )} + {/* 검색 및 필터 영역 */}
{/* 검색 입력 */}
-
- - -
- {actions.showSearchButton && ( )}
- {/* 필터 영역 */} + {/* 기존 필터 영역 (이제는 사용하지 않음) */} {filters.length > 0 && (
@@ -352,6 +452,46 @@ export const DataTableTemplate: React.FC = ({
)} + + {/* 🆕 검색 필터 설정 다이얼로그 */} + + + + 검색 필터 설정 + + 표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다. + + + +
+ {defaultColumns.map((column) => ( +
+ handleToggleFilterColumn(column.id)} + /> + +
+ ))} +
+ + + + + +
+
); }; diff --git a/frontend/components/screen/templates/NumberingRuleTemplate.ts b/frontend/components/screen/templates/NumberingRuleTemplate.ts new file mode 100644 index 00000000..ee386c4b --- /dev/null +++ b/frontend/components/screen/templates/NumberingRuleTemplate.ts @@ -0,0 +1,77 @@ +/** + * 채번 규칙 템플릿 + * 화면관리 시스템에 등록하여 드래그앤드롭으로 사용 + */ + +import { Hash } from "lucide-react"; + +export const getDefaultNumberingRuleConfig = () => ({ + template_code: "numbering-rule-designer", + template_name: "코드 채번 규칙", + template_name_eng: "Numbering Rule Designer", + description: "코드 자동 채번 규칙을 설정하는 컴포넌트", + category: "admin" as const, + icon_name: "hash", + default_size: { + width: 1200, + height: 800, + }, + layout_config: { + components: [ + { + type: "numbering-rule" as const, + label: "채번 규칙 설정", + position: { x: 0, y: 0 }, + size: { width: 1200, height: 800 }, + ruleConfig: { + ruleId: "new-rule", + ruleName: "새 채번 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + }, + maxRules: 6, + style: { + padding: "16px", + backgroundColor: "#ffffff", + }, + }, + ], + }, +}); + +/** + * 템플릿 패널에서 사용할 컴포넌트 정보 + */ +export const numberingRuleTemplate = { + id: "numbering-rule", + name: "채번 규칙", + description: "코드 자동 채번 규칙 설정", + category: "admin" as const, + icon: Hash, + defaultSize: { width: 1200, height: 800 }, + components: [ + { + type: "numbering-rule" as const, + widgetType: undefined, + label: "채번 규칙 설정", + position: { x: 0, y: 0 }, + size: { width: 1200, height: 800 }, + style: { + padding: "16px", + backgroundColor: "#ffffff", + }, + ruleConfig: { + ruleId: "new-rule", + ruleName: "새 채번 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + }, + maxRules: 6, + }, + ], +}; + diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index bffae228..fd24ea19 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { FlowComponent } from "@/types/screen-management"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { AlertCircle, Loader2, ChevronUp, Filter, X } from "lucide-react"; +import { AlertCircle, Loader2, ChevronUp, Filter, X, Layers, ChevronDown, ChevronRight } from "lucide-react"; import { getFlowById, getAllStepCounts, @@ -40,6 +40,14 @@ import { Input } from "@/components/ui/input"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useAuth } from "@/hooks/useAuth"; +// 그룹화된 데이터 인터페이스 +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; +} + interface FlowWidgetProps { component: FlowComponent; onStepClick?: (stepId: number, stepName: string) => void; @@ -58,6 +66,28 @@ export function FlowWidget({ const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { user } = useAuth(); // 사용자 정보 가져오기 + // 숫자 포맷팅 함수 + const formatValue = (value: any): string => { + if (value === null || value === undefined || value === "") { + return "-"; + } + + // 숫자 타입이거나 숫자로 변환 가능한 문자열인 경우 포맷팅 + if (typeof value === "number") { + return value.toLocaleString("ko-KR"); + } + + if (typeof value === "string") { + const numValue = parseFloat(value); + // 숫자로 변환 가능하고, 변환 후 원래 값과 같은 경우에만 포맷팅 + if (!isNaN(numValue) && numValue.toString() === value.trim()) { + return numValue.toLocaleString("ko-KR"); + } + } + + return String(value); + }; + // 🆕 전역 상태 관리 const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep); const resetFlow = useFlowStepStore((state) => state.resetFlow); @@ -84,6 +114,11 @@ export function FlowWidget({ const [allAvailableColumns, setAllAvailableColumns] = useState([]); // 전체 컬럼 목록 const [filteredData, setFilteredData] = useState([]); // 필터링된 데이터 + // 🆕 그룹 설정 관련 상태 + const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 + const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); // 접힌 그룹 + /** * 🆕 컬럼 표시 결정 함수 * 1순위: 플로우 스텝 기본 설정 (displayConfig) @@ -125,6 +160,12 @@ export function FlowWidget({ return `flowWidget_searchFilters_${user.userId}_${flowId}_${selectedStepId}`; }, [flowId, selectedStepId, user?.userId]); + // 🆕 그룹 설정 localStorage 키 생성 + const groupSettingKey = useMemo(() => { + if (!selectedStepId) return null; + return `flowWidget_groupSettings_step_${selectedStepId}`; + }, [selectedStepId]); + // 🆕 저장된 필터 설정 불러오기 useEffect(() => { if (!filterSettingKey || stepDataColumns.length === 0 || !user?.userId) return; @@ -141,43 +182,30 @@ export function FlowWidget({ // 초기값: 빈 필터 (사용자가 선택해야 함) setSearchFilterColumns(new Set()); } - - // 이전 사용자의 필터 설정 정리 (사용자 ID가 다른 키들 제거) - if (typeof window !== "undefined") { - const currentUserId = user.userId; - const keysToRemove: string[] = []; - - // localStorage의 모든 키를 확인 - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith("flowWidget_searchFilters_")) { - // 키 형식: flowWidget_searchFilters_${userId}_${flowId}_${stepId} - // split("_")를 하면 ["flowWidget", "searchFilters", "사용자ID", "플로우ID", "스텝ID"] - // 따라서 userId는 parts[2]입니다 - const parts = key.split("_"); - if (parts.length >= 3) { - const userIdFromKey = parts[2]; // flowWidget_searchFilters_ 다음이 userId - // 현재 사용자 ID와 다른 사용자의 설정은 제거 - if (userIdFromKey !== currentUserId) { - keysToRemove.push(key); - } - } - } - } - - // 이전 사용자의 설정 제거 - if (keysToRemove.length > 0) { - keysToRemove.forEach(key => { - localStorage.removeItem(key); - }); - } - } } catch (error) { console.error("필터 설정 불러오기 실패:", error); setSearchFilterColumns(new Set()); } }, [filterSettingKey, stepDataColumns, user?.userId]); + // 🆕 저장된 그룹 설정 불러오기 + useEffect(() => { + if (!groupSettingKey || stepDataColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + // 현재 단계에 표시되는 컬럼만 필터링 + const validGroups = savedGroups.filter((col: string) => stepDataColumns.includes(col)); + setGroupByColumns(validGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + setGroupByColumns([]); + } + }, [groupSettingKey, stepDataColumns]); + // 🆕 필터 설정 저장 const saveFilterSettings = useCallback(() => { if (!filterSettingKey) return; @@ -225,6 +253,92 @@ export function FlowWidget({ setFilteredData([]); }, []); + // 🆕 그룹 설정 저장 + const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } + }, [groupSettingKey, groupByColumns]); + + // 🆕 그룹 컬럼 토글 + const toggleGroupColumn = useCallback((columnName: string) => { + setGroupByColumns((prev) => { + if (prev.includes(columnName)) { + return prev.filter((col) => col !== columnName); + } else { + return [...prev, columnName]; + } + }); + }, []); + + // 🆕 그룹 펼치기/접기 토글 + const toggleGroupCollapse = useCallback((groupKey: string) => { + setCollapsedGroups((prev) => { + const newSet = new Set(prev); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + return newSet; + }); + }, []); + + // 🆕 그룹 해제 + const clearGrouping = useCallback(() => { + setGroupByColumns([]); + setCollapsedGroups(new Set()); + if (groupSettingKey) { + localStorage.removeItem(groupSettingKey); + } + toast.success("그룹이 해제되었습니다"); + }, [groupSettingKey]); + + // 🆕 데이터 그룹화 + const groupedData = useMemo((): GroupedData[] => { + const dataToGroup = filteredData.length > 0 ? filteredData : stepData; + + if (groupByColumns.length === 0 || dataToGroup.length === 0) return []; + + const grouped = new Map(); + + dataToGroup.forEach((item) => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupByColumns.map((col) => { + const value = item[col]; + const label = columnLabels[col] || col; + return `${label}:${value !== null && value !== undefined ? value : "-"}`; + }); + const groupKey = keyParts.join(" > "); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupByColumns.forEach((col) => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); + }, [filteredData, stepData, groupByColumns, columnLabels]); + // 🆕 검색 값이 변경될 때마다 자동 검색 (useEffect로 직접 처리) useEffect(() => { if (!stepData || stepData.length === 0) { @@ -657,17 +771,6 @@ export function FlowWidget({ return (
- {/* 플로우 제목 */} -
-
-

{flowData.name}

-
- - {flowData.description && ( -

{flowData.description}

- )} -
- {/* 플로우 스텝 목록 */}
{steps.map((step, index) => ( @@ -698,7 +801,7 @@ export function FlowWidget({ }`} > - {stepCounts[step.id] || 0} + {(stepCounts[step.id] || 0).toLocaleString("ko-KR")}
@@ -754,85 +857,115 @@ export function FlowWidget({ {/* 선택된 스텝의 데이터 리스트 */} {selectedStepId !== null && (
- {/* 헤더 - 자동 높이 */} -
-
-
-

- {steps.find((s) => s.id === selectedStepId)?.stepName} -

-

- 총 {stepData.length}건의 데이터 - {filteredData.length > 0 && ( - (필터링: {filteredData.length}건) + {/* 필터 및 그룹 설정 */} + {stepDataColumns.length > 0 && ( + <> +

+
+ {/* 검색 필터 입력 영역 */} + {searchFilterColumns.size > 0 && ( + <> + {Array.from(searchFilterColumns).map((col) => ( + + setSearchValues((prev) => ({ + ...prev, + [col]: e.target.value, + })) + } + placeholder={`${columnLabels[col] || col} 검색...`} + className="h-8 text-xs w-40" + /> + ))} + {Object.keys(searchValues).length > 0 && ( + + )} + )} - {selectedRows.size > 0 && ( - ({selectedRows.size}건 선택됨) - )} -

+ + {/* 필터/그룹 설정 버튼 */} +
+ + +
+
- {/* 🆕 필터 설정 버튼 */} - {stepDataColumns.length > 0 && ( - - )} -
- - {/* 🆕 검색 필터 입력 영역 */} - {searchFilterColumns.size > 0 && ( -
-
- {Object.keys(searchValues).length > 0 && ( - - )} -
- -
- {Array.from(searchFilterColumns).map((col) => ( -
- - - setSearchValues((prev) => ({ - ...prev, - [col]: e.target.value, - })) - } - placeholder={`${columnLabels[col] || col} 검색...`} - className="h-8 text-xs" - /> -
- ))} + {/* 🆕 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+
)} -
+ + )} {/* 데이터 영역 - 고정 높이 + 스크롤 */} {stepDataLoading ? ( @@ -884,13 +1017,7 @@ export function FlowWidget({ {stepDataColumns.map((col) => (
{columnLabels[col] || col}: - - {row[col] !== null && row[col] !== undefined ? ( - String(row[col]) - ) : ( - - - )} - + {formatValue(row[col])}
))}
@@ -924,33 +1051,87 @@ export function FlowWidget({ - {paginatedStepData.map((row, pageIndex) => { - const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; - return ( - - {allowDataMove && ( - - toggleRowSelection(actualIndex)} - /> + {groupByColumns.length > 0 && groupedData.length > 0 ? ( + // 그룹화된 렌더링 + groupedData.flatMap((group) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + const groupRows = [ + + +
toggleGroupCollapse(group.groupKey)} + > + {isCollapsed ? ( + + ) : ( + + )} + {group.groupKey} + ({group.count}건) +
- )} - {stepDataColumns.map((col) => ( - - {row[col] !== null && row[col] !== undefined ? ( - String(row[col]) - ) : ( - - - )} - - ))} -
- ); - })} +
, + ]; + + if (!isCollapsed) { + const dataRows = group.items.map((row, itemIndex) => { + const actualIndex = displayData.indexOf(row); + return ( + + {allowDataMove && ( + + toggleRowSelection(actualIndex)} + /> + + )} + {stepDataColumns.map((col) => ( + + {formatValue(row[col])} + + ))} + + ); + }); + groupRows.push(...dataRows); + } + + return groupRows; + }) + ) : ( + // 일반 렌더링 (그룹 없음) + paginatedStepData.map((row, pageIndex) => { + const actualIndex = (stepDataPage - 1) * stepDataPageSize + pageIndex; + return ( + + {allowDataMove && ( + + toggleRowSelection(actualIndex)} + /> + + )} + {stepDataColumns.map((col) => ( + + {formatValue(row[col])} + + ))} + + ); + }) + )}
@@ -964,7 +1145,7 @@ export function FlowWidget({ {/* 왼쪽: 페이지 정보 + 페이지 크기 선택 */}
- 페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length}건) + 페이지 {stepDataPage} / {totalStepDataPages} (총 {stepData.length.toLocaleString("ko-KR")}건)
표시 개수: @@ -1150,6 +1331,63 @@ export function FlowWidget({ + + {/* 🆕 그룹 설정 다이얼로그 */} + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {stepDataColumns.map((col) => ( +
+ toggleGroupColumn(col)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹:{" "} + + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
); } diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx new file mode 100644 index 00000000..03dec3ba --- /dev/null +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -0,0 +1,210 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { TabsComponent, TabItem, ScreenDefinition } from "@/types"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, FileQuestion } from "lucide-react"; +import { screenApi } from "@/lib/api/screen"; +import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; + +interface TabsWidgetProps { + component: TabsComponent; + isPreview?: boolean; +} + +/** + * 탭 위젯 컴포넌트 + * 각 탭에 다른 화면을 표시할 수 있습니다 + */ +export const TabsWidget: React.FC = ({ component, isPreview = false }) => { + // componentConfig에서 설정 읽기 (새 컴포넌트 시스템) + const config = (component as any).componentConfig || component; + const { tabs = [], defaultTab, orientation = "horizontal", variant = "default" } = config; + + // console.log("🔍 TabsWidget 렌더링:", { + // component, + // componentConfig: (component as any).componentConfig, + // tabs, + // tabsLength: tabs.length + // }); + + const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id || ""); + const [loadedScreens, setLoadedScreens] = useState>({}); + const [loadingScreens, setLoadingScreens] = useState>({}); + const [screenErrors, setScreenErrors] = useState>({}); + + // 탭 변경 시 화면 로드 + useEffect(() => { + if (!activeTab) return; + + const currentTab = tabs.find((tab) => tab.id === activeTab); + if (!currentTab || !currentTab.screenId) return; + + // 이미 로드된 화면이면 스킵 + if (loadedScreens[activeTab]) return; + + // 이미 로딩 중이면 스킵 + if (loadingScreens[activeTab]) return; + + // 화면 로드 시작 + loadScreen(activeTab, currentTab.screenId); + }, [activeTab, tabs]); + + const loadScreen = async (tabId: string, screenId: number) => { + setLoadingScreens((prev) => ({ ...prev, [tabId]: true })); + setScreenErrors((prev) => ({ ...prev, [tabId]: "" })); + + try { + const layoutData = await screenApi.getLayout(screenId); + + if (layoutData) { + setLoadedScreens((prev) => ({ + ...prev, + [tabId]: { + screenId, + layout: layoutData, + }, + })); + } else { + setScreenErrors((prev) => ({ + ...prev, + [tabId]: "화면을 불러올 수 없습니다", + })); + } + } catch (error: any) { + setScreenErrors((prev) => ({ + ...prev, + [tabId]: error.message || "화면 로드 중 오류가 발생했습니다", + })); + } finally { + setLoadingScreens((prev) => ({ ...prev, [tabId]: false })); + } + }; + + // 탭 콘텐츠 렌더링 + const renderTabContent = (tab: TabItem) => { + const isLoading = loadingScreens[tab.id]; + const error = screenErrors[tab.id]; + const screenData = loadedScreens[tab.id]; + + // 로딩 중 + if (isLoading) { + return ( +
+ +

화면을 불러오는 중...

+
+ ); + } + + // 에러 발생 + if (error) { + return ( +
+ +
+

화면 로드 실패

+

{error}

+
+
+ ); + } + + // 화면 ID가 없는 경우 + if (!tab.screenId) { + return ( +
+ +
+

화면이 할당되지 않았습니다

+

상세설정에서 화면을 선택하세요

+
+
+ ); + } + + // 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링 + if (screenData && screenData.layout && screenData.layout.components) { + const components = screenData.layout.components; + const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 }; + + return ( +
+
+ {components.map((comp) => ( + + ))} +
+
+ ); + } + + return ( +
+ +
+

화면 데이터를 불러올 수 없습니다

+
+
+ ); + }; + + // 빈 탭 목록 + if (tabs.length === 0) { + return ( + +
+

탭이 없습니다

+

상세설정에서 탭을 추가하세요

+
+
+ ); + } + + return ( +
+ + + {tabs.map((tab) => ( + + {tab.label} + {tab.screenName && ( + + {tab.screenName} + + )} + + ))} + + + {tabs.map((tab) => ( + + {renderTabContent(tab)} + + ))} + +
+ ); +}; + diff --git a/frontend/components/screen/widgets/types/ButtonWidget.tsx b/frontend/components/screen/widgets/types/ButtonWidget.tsx index 0fc7ad45..6bc9e1ff 100644 --- a/frontend/components/screen/widgets/types/ButtonWidget.tsx +++ b/frontend/components/screen/widgets/types/ButtonWidget.tsx @@ -31,7 +31,11 @@ export const ButtonWidget: React.FC = ({ onClick={handleClick} disabled={disabled || readonly} className={`rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${className || ""} `} - style={style} + style={{ + ...style, + width: "100%", + height: "100%", + }} title={config?.tooltip || placeholder} > {config?.label || config?.text || value || placeholder || "버튼"} diff --git a/frontend/constants/company.ts b/frontend/constants/company.ts index 2c39247d..f63dc341 100644 --- a/frontend/constants/company.ts +++ b/frontend/constants/company.ts @@ -74,6 +74,12 @@ export const MOCK_COMPANIES: Company[] = [ // 새 회사 등록 시 기본값 export const DEFAULT_COMPANY_FORM_DATA = { company_name: "", + business_registration_number: "", + representative_name: "", + representative_phone: "", + email: "", + website: "", + address: "", }; // 페이징 관련 상수 diff --git a/frontend/hooks/useCompanyManagement.ts b/frontend/hooks/useCompanyManagement.ts index f25a881b..eba448ef 100644 --- a/frontend/hooks/useCompanyManagement.ts +++ b/frontend/hooks/useCompanyManagement.ts @@ -144,6 +144,12 @@ export const useCompanyManagement = () => { selectedCompany: company, formData: { company_name: company.company_name, + business_registration_number: company.business_registration_number || "", + representative_name: company.representative_name || "", + representative_phone: company.representative_phone || "", + email: company.email || "", + website: company.website || "", + address: company.address || "", }, }); }, []); @@ -175,6 +181,10 @@ export const useCompanyManagement = () => { setError("회사명을 입력해주세요."); return false; } + if (!modalState.formData.business_registration_number.trim()) { + setError("사업자등록번호를 입력해주세요."); + return false; + } setIsLoading(true); setError(null); @@ -199,6 +209,10 @@ export const useCompanyManagement = () => { setError("올바른 데이터를 입력해주세요."); return false; } + if (!modalState.formData.business_registration_number.trim()) { + setError("사업자등록번호를 입력해주세요."); + return false; + } setIsLoading(true); setError(null); @@ -206,6 +220,12 @@ export const useCompanyManagement = () => { try { await companyAPI.update(modalState.selectedCompany.company_code, { company_name: modalState.formData.company_name, + business_registration_number: modalState.formData.business_registration_number, + representative_name: modalState.formData.representative_name, + representative_phone: modalState.formData.representative_phone, + email: modalState.formData.email, + website: modalState.formData.website, + address: modalState.formData.address, status: modalState.selectedCompany.status, }); closeModal(); diff --git a/frontend/lib/api/department.ts b/frontend/lib/api/department.ts new file mode 100644 index 00000000..2486d11f --- /dev/null +++ b/frontend/lib/api/department.ts @@ -0,0 +1,157 @@ +/** + * 부서 관리 API 클라이언트 + */ + +import { apiClient } from "./client"; +import { Department, DepartmentMember, DepartmentFormData } from "@/types/department"; + +/** + * 부서 목록 조회 (회사별) + */ +export async function getDepartments(companyCode: string) { + try { + const url = `/departments/companies/${companyCode}/departments`; + const response = await apiClient.get<{ success: boolean; data: Department[] }>(url); + return response.data; + } catch (error: any) { + console.error("부서 목록 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 상세 조회 + */ +export async function getDepartment(deptCode: string) { + try { + const response = await apiClient.get<{ success: boolean; data: Department }>(`/departments/${deptCode}`); + return response.data; + } catch (error: any) { + console.error("부서 상세 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 생성 + */ +export async function createDepartment(companyCode: string, data: DepartmentFormData) { + try { + const response = await apiClient.post<{ success: boolean; data: Department }>( + `/departments/companies/${companyCode}/departments`, + data, + ); + return response.data; + } catch (error: any) { + console.error("부서 생성 실패:", error); + const isDuplicate = error.response?.status === 409; + return { + success: false, + error: error.response?.data?.message || error.message, + isDuplicate, + }; + } +} + +/** + * 부서 수정 + */ +export async function updateDepartment(deptCode: string, data: DepartmentFormData) { + try { + const response = await apiClient.put<{ success: boolean; data: Department }>(`/departments/${deptCode}`, data); + return response.data; + } catch (error: any) { + console.error("부서 수정 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서 삭제 + */ +export async function deleteDepartment(deptCode: string) { + try { + const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}`); + return response.data; + } catch (error: any) { + console.error("부서 삭제 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서원 목록 조회 + */ +export async function getDepartmentMembers(deptCode: string) { + try { + const response = await apiClient.get<{ success: boolean; data: DepartmentMember[] }>( + `/departments/${deptCode}/members`, + ); + return response.data; + } catch (error: any) { + console.error("부서원 목록 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 사용자 검색 (부서원 추가용) + */ +export async function searchUsers(companyCode: string, search: string) { + try { + const response = await apiClient.get<{ success: boolean; data: any[] }>( + `/departments/companies/${companyCode}/users/search`, + { params: { search } }, + ); + return response.data; + } catch (error: any) { + console.error("사용자 검색 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 부서원 추가 + */ +export async function addDepartmentMember(deptCode: string, userId: string) { + try { + const response = await apiClient.post<{ success: boolean; message?: string }>(`/departments/${deptCode}/members`, { + user_id: userId, + }); + return response.data; + } catch (error: any) { + console.error("부서원 추가 실패:", error); + const isDuplicate = error.response?.status === 409; + return { + success: false, + error: error.response?.data?.message || error.message, + isDuplicate, + }; + } +} + +/** + * 부서원 제거 + */ +export async function removeDepartmentMember(deptCode: string, userId: string) { + try { + const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}/members/${userId}`); + return response.data; + } catch (error: any) { + console.error("부서원 제거 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 주 부서 설정 + */ +export async function setPrimaryDepartment(deptCode: string, userId: string) { + try { + const response = await apiClient.put<{ success: boolean }>(`/departments/${deptCode}/members/${userId}/primary`); + return response.data; + } catch (error: any) { + console.error("주 부서 설정 실패:", error); + return { success: false, error: error.message }; + } +} diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index 77bd4606..455ab5eb 100644 --- a/frontend/lib/api/dynamicForm.ts +++ b/frontend/lib/api/dynamicForm.ts @@ -410,6 +410,128 @@ export class DynamicFormApi { }; } } + + /** + * 테이블 데이터 조회 (페이징 + 검색) + * @param tableName 테이블명 + * @param params 검색 파라미터 + * @returns 테이블 데이터 + */ + static async getTableData( + tableName: string, + params?: { + page?: number; + pageSize?: number; + search?: string; + sortBy?: string; + sortOrder?: "asc" | "desc"; + filters?: Record; + }, + ): Promise> { + try { + console.log("📊 테이블 데이터 조회 요청:", { tableName, params }); + + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params || {}); + + console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data); + console.log("🔍 response.data 상세:", { + type: typeof response.data, + isArray: Array.isArray(response.data), + keys: response.data ? Object.keys(response.data) : [], + hasData: response.data?.data !== undefined, + dataType: response.data?.data ? typeof response.data.data : "N/A", + dataIsArray: response.data?.data ? Array.isArray(response.data.data) : false, + dataLength: response.data?.data ? (Array.isArray(response.data.data) ? response.data.data.length : "not array") : "no data", + // 중첩 구조 확인 + dataDataExists: response.data?.data?.data !== undefined, + dataDataIsArray: response.data?.data?.data ? Array.isArray(response.data.data.data) : false, + dataDataLength: response.data?.data?.data ? (Array.isArray(response.data.data.data) ? response.data.data.data.length : "not array") : "no nested data", + }); + + // API 응답 구조: { data: [...], total, page, size, totalPages } + // 또는 중첩: { success: true, data: { data: [...], total, ... } } + // data 배열만 추출 + let tableData: any[] = []; + + if (Array.isArray(response.data)) { + // 케이스 1: 응답이 배열이면 그대로 사용 + console.log("✅ 케이스 1: 응답이 배열"); + tableData = response.data; + } else if (response.data && Array.isArray(response.data.data)) { + // 케이스 2: 응답이 { data: [...] } 구조면 data 배열 추출 + console.log("✅ 케이스 2: 응답이 { data: [...] } 구조"); + tableData = response.data.data; + } else if (response.data?.data?.data && Array.isArray(response.data.data.data)) { + // 케이스 2-1: 중첩 구조 { success: true, data: { data: [...] } } + console.log("✅ 케이스 2-1: 중첩 구조 { data: { data: [...] } }"); + tableData = response.data.data.data; + } else if (response.data && typeof response.data === "object") { + // 케이스 3: 응답이 객체면 배열로 감싸기 (최후의 수단) + console.log("⚠️ 케이스 3: 응답이 객체 (배열로 감싸기)"); + tableData = [response.data]; + } + + console.log("✅ 테이블 데이터 추출 완료:", { + originalType: typeof response.data, + isArray: Array.isArray(response.data), + hasDataProperty: response.data?.data !== undefined, + extractedCount: tableData.length, + firstRow: tableData[0], + allRows: tableData, + }); + + return { + success: true, + data: tableData, + message: "테이블 데이터 조회가 완료되었습니다.", + }; + } catch (error: any) { + console.error("❌ 테이블 데이터 조회 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "테이블 데이터 조회 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } + + /** + * 엑셀 업로드 (대량 데이터 삽입/업데이트) + * @param payload 업로드 데이터 + * @returns 업로드 결과 + */ + static async uploadExcelData(payload: { + tableName: string; + data: any[]; + uploadMode: "insert" | "update" | "upsert"; + keyColumn?: string; + }): Promise> { + try { + console.log("📤 엑셀 업로드 요청:", payload); + + const response = await apiClient.post(`/dynamic-form/excel-upload`, payload); + + console.log("✅ 엑셀 업로드 성공:", response.data); + return { + success: true, + data: response.data, + message: "엑셀 파일이 성공적으로 업로드되었습니다.", + }; + } catch (error: any) { + console.error("❌ 엑셀 업로드 실패:", error); + + const errorMessage = error.response?.data?.message || error.message || "엑셀 업로드 중 오류가 발생했습니다."; + + return { + success: false, + message: errorMessage, + errorCode: error.response?.data?.errorCode, + }; + } + } } // 편의를 위한 기본 export diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts new file mode 100644 index 00000000..7702ea08 --- /dev/null +++ b/frontend/lib/api/numberingRule.ts @@ -0,0 +1,81 @@ +/** + * 채번 규칙 관리 API 클라이언트 + */ + +import { apiClient } from "./client"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export async function getNumberingRules(): Promise> { + try { + const response = await apiClient.get("/numbering-rules"); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 목록 조회 실패" }; + } +} + +export async function getNumberingRuleById(ruleId: string): Promise> { + try { + const response = await apiClient.get(`/numbering-rules/${ruleId}`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 조회 실패" }; + } +} + +export async function createNumberingRule( + config: NumberingRuleConfig +): Promise> { + try { + const response = await apiClient.post("/numbering-rules", config); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 생성 실패" }; + } +} + +export async function updateNumberingRule( + ruleId: string, + config: Partial +): Promise> { + try { + const response = await apiClient.put(`/numbering-rules/${ruleId}`, config); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 수정 실패" }; + } +} + +export async function deleteNumberingRule(ruleId: string): Promise> { + try { + const response = await apiClient.delete(`/numbering-rules/${ruleId}`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 삭제 실패" }; + } +} + +export async function generateCode(ruleId: string): Promise> { + try { + const response = await apiClient.post(`/numbering-rules/${ruleId}/generate`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "코드 생성 실패" }; + } +} + +export async function resetSequence(ruleId: string): Promise> { + try { + const response = await apiClient.post(`/numbering-rules/${ruleId}/reset`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "시퀀스 초기화 실패" }; + } +} diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 695e5a51..a37fbdff 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -242,6 +242,12 @@ export const tableTypeApi = { return data.columns || data || []; }, + // 컬럼 입력 타입 정보 조회 + getColumnInputTypes: async (tableName: string): Promise => { + const response = await apiClient.get(`/table-management/tables/${tableName}/web-types`); + return response.data.data || []; + }, + // 컬럼 웹 타입 설정 setColumnWebType: async ( tableName: string, @@ -307,6 +313,21 @@ export const tableTypeApi = { deleteTableData: async (tableName: string, data: Record[] | { ids: string[] }): Promise => { await apiClient.delete(`/table-management/tables/${tableName}/delete`, { data }); }, + + // 🆕 단일 레코드 조회 (자동 입력용) + getTableRecord: async ( + tableName: string, + filterColumn: string, + filterValue: any, + displayColumn: string, + ): Promise<{ value: any; record: Record }> => { + const response = await apiClient.post(`/table-management/tables/${tableName}/record`, { + filterColumn, + filterValue, + displayColumn, + }); + return response.data.data; + }, }; // 메뉴-화면 할당 관련 API diff --git a/frontend/lib/registry/DynamicWebTypeRenderer.tsx b/frontend/lib/registry/DynamicWebTypeRenderer.tsx index b2578648..bccb9f50 100644 --- a/frontend/lib/registry/DynamicWebTypeRenderer.tsx +++ b/frontend/lib/registry/DynamicWebTypeRenderer.tsx @@ -21,12 +21,16 @@ export const DynamicWebTypeRenderer: React.FC = ({ const { webTypes } = useWebTypes({ active: "Y" }); // 디버깅: 전달받은 웹타입과 props 정보 로깅 - console.log("🔍 DynamicWebTypeRenderer 호출:", { - webType, - propsKeys: Object.keys(props), - component: props.component, - isFileComponent: props.component?.type === "file" || webType === "file", - }); + if (webType === "button") { + console.log("🔘 DynamicWebTypeRenderer 버튼 호출:", { + webType, + component: props.component, + position: props.component?.position, + size: props.component?.size, + style: props.component?.style, + config, + }); + } const webTypeDefinition = useMemo(() => { return WebTypeRegistry.getWebType(webType); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index f2d6e554..5bf11eec 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -223,6 +223,8 @@ export const ButtonPrimaryComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) @@ -247,8 +249,8 @@ export const ButtonPrimaryComponent: React.FC = ({ // 추가 안전장치: 모든 로딩 토스트 제거 toast.dismiss(); - // UI 전환 액션(edit, modal, navigate)을 제외하고만 로딩 토스트 표시 - const silentActions = ["edit", "modal", "navigate"]; + // UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음 + const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; if (!silentActions.includes(actionConfig.type)) { currentLoadingToastRef.current = toast.loading( actionConfig.type === "save" @@ -274,9 +276,9 @@ export const ButtonPrimaryComponent: React.FC = ({ // 실패한 경우 오류 처리 if (!success) { - // UI 전환 액션(edit, modal, navigate)은 에러도 조용히 처리 - const silentActions = ["edit", "modal", "navigate"]; - if (silentActions.includes(actionConfig.type)) { + // UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시) + const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; + if (silentErrorActions.includes(actionConfig.type)) { return; } // 기본 에러 메시지 결정 @@ -302,8 +304,10 @@ export const ButtonPrimaryComponent: React.FC = ({ } // 성공한 경우에만 성공 토스트 표시 - // edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요) - if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") { + // edit, modal, navigate, excel_upload, barcode_scan 액션은 조용히 처리 + // (UI 전환만 하거나 모달 내부에서 자체적으로 메시지 표시) + const silentSuccessActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; + if (!silentSuccessActions.includes(actionConfig.type)) { // 기본 성공 메시지 결정 const defaultSuccessMessage = actionConfig.type === "save" diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 64bdaac9..0912afd7 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -185,6 +185,9 @@ export const CardDisplayComponent: React.FC = ({ position: "relative", backgroundColor: "transparent", }; + + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + // 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정 if (isDesignMode) { componentStyle.border = "1px dashed hsl(var(--border))"; diff --git a/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx b/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx index 0fd31f25..2f2c5622 100644 --- a/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx +++ b/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx @@ -48,6 +48,8 @@ export const CheckboxBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/date-input/DateInputComponent.tsx b/frontend/lib/registry/components/date-input/DateInputComponent.tsx index 29b25029..928df3de 100644 --- a/frontend/lib/registry/components/date-input/DateInputComponent.tsx +++ b/frontend/lib/registry/components/date-input/DateInputComponent.tsx @@ -204,6 +204,8 @@ export const DateInputComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx index 45d5089f..5cc4fcfd 100644 --- a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx +++ b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx @@ -36,6 +36,8 @@ export const DividerLineComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx index bda6fd8b..13a7ac4f 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx @@ -36,6 +36,8 @@ export const ImageDisplayComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index f2ac68c2..315cf1da 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -39,6 +39,7 @@ import "./split-panel-layout/SplitPanelLayoutRenderer"; import "./map/MapRenderer"; import "./repeater-field-group/RepeaterFieldGroupRenderer"; import "./flow-widget/FlowWidgetRenderer"; +import "./numbering-rule/NumberingRuleRenderer"; /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/number-input/NumberInputComponent.tsx b/frontend/lib/registry/components/number-input/NumberInputComponent.tsx index e5d49328..3f41505c 100644 --- a/frontend/lib/registry/components/number-input/NumberInputComponent.tsx +++ b/frontend/lib/registry/components/number-input/NumberInputComponent.tsx @@ -43,6 +43,8 @@ export const NumberInputComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx new file mode 100644 index 00000000..78c366fd --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; +import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner"; +import { NumberingRuleComponentConfig } from "./types"; + +interface NumberingRuleWrapperProps { + config: NumberingRuleComponentConfig; + onChange?: (config: NumberingRuleComponentConfig) => void; + isPreview?: boolean; +} + +export const NumberingRuleWrapper: React.FC = ({ + config, + onChange, + isPreview = false, +}) => { + return ( +
+ +
+ ); +}; + +export const NumberingRuleComponent = NumberingRuleWrapper; diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx new file mode 100644 index 00000000..332d4055 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { NumberingRuleComponentConfig } from "./types"; + +interface NumberingRuleConfigPanelProps { + config: NumberingRuleComponentConfig; + onChange: (config: NumberingRuleComponentConfig) => void; +} + +export const NumberingRuleConfigPanel: React.FC = ({ + config, + onChange, +}) => { + return ( +
+
+ + + onChange({ ...config, maxRules: parseInt(e.target.value) || 6 }) + } + className="h-9" + /> +

+ 한 규칙에 추가할 수 있는 최대 파트 개수 (1-10) +

+
+ +
+
+ +

+ 편집 기능을 비활성화합니다 +

+
+ + onChange({ ...config, readonly: checked }) + } + /> +
+ +
+
+ +

+ 코드 미리보기를 항상 표시합니다 +

+
+ + onChange({ ...config, showPreview: checked }) + } + /> +
+ +
+
+ +

+ 저장된 규칙 목록을 표시합니다 +

+
+ + onChange({ ...config, showRuleList: checked }) + } + /> +
+ +
+ + +

+ 규칙 파트 카드의 배치 방향 +

+
+
+ ); +}; diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx new file mode 100644 index 00000000..29c98b45 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { NumberingRuleDefinition } from "./index"; +import { NumberingRuleComponent } from "./NumberingRuleComponent"; + +/** + * 채번 규칙 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class NumberingRuleRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = NumberingRuleDefinition; + + render(): React.ReactElement { + return ; + } + + /** + * 채번 규칙 컴포넌트 특화 메서드 + */ + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; +} + +// 자동 등록 실행 +NumberingRuleRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + NumberingRuleRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/numbering-rule/README.md b/frontend/lib/registry/components/numbering-rule/README.md new file mode 100644 index 00000000..5d04d894 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/README.md @@ -0,0 +1,102 @@ +# 코드 채번 규칙 컴포넌트 + +## 개요 + +시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다. + +## 주요 기능 + +- **좌우 분할 레이아웃**: 좌측에서 규칙 목록, 우측에서 편집 +- **동적 파트 시스템**: 최대 6개의 파트를 자유롭게 조합 +- **실시간 미리보기**: 설정 즉시 생성될 코드 확인 +- **다양한 파트 유형**: 접두사, 순번, 날짜, 연도, 월, 커스텀 + +## 생성 코드 예시 + +- 제품 코드: `PROD-20251104-0001` +- 프로젝트 코드: `PRJ-2025-001` +- 거래처 코드: `CUST-A-0001` + +## 파트 유형 + +### 1. 접두사 (prefix) +고정된 문자열을 코드 앞에 추가합니다. +- 예: `PROD`, `PRJ`, `CUST` + +### 2. 순번 (sequence) +자동으로 증가하는 번호를 생성합니다. +- 자릿수 설정 가능 (1-10) +- 시작 번호 설정 가능 +- 예: `0001`, `00001` + +### 3. 날짜 (date) +현재 날짜를 다양한 형식으로 추가합니다. +- YYYY: 2025 +- YYYYMMDD: 20251104 +- YYMMDD: 251104 + +### 4. 연도 (year) +현재 연도를 추가합니다. +- YYYY: 2025 +- YY: 25 + +### 5. 월 (month) +현재 월을 2자리로 추가합니다. +- 예: 01, 02, ..., 12 + +### 6. 사용자 정의 (custom) +원하는 값을 직접 입력합니다. + +## 생성 방식 + +### 자동 생성 (auto) +시스템이 자동으로 값을 생성합니다. + +### 직접 입력 (manual) +사용자가 값을 직접 입력합니다. + +## 설정 옵션 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `maxRules` | number | 6 | 최대 파트 개수 | +| `readonly` | boolean | false | 읽기 전용 모드 | +| `showPreview` | boolean | true | 미리보기 표시 | +| `showRuleList` | boolean | true | 규칙 목록 표시 | +| `cardLayout` | "vertical" \| "horizontal" | "vertical" | 카드 배치 방향 | + +## 사용 예시 + +```typescript + +``` + +## 데이터베이스 구조 + +### numbering_rules (마스터 테이블) +- 규칙 ID, 규칙명, 구분자 +- 초기화 주기, 현재 시퀀스 +- 적용 대상 테이블/컬럼 + +### numbering_rule_parts (파트 테이블) +- 파트 순서, 파트 유형 +- 생성 방식, 설정 (JSONB) + +## API 엔드포인트 + +- `GET /api/numbering-rules` - 규칙 목록 조회 +- `POST /api/numbering-rules` - 규칙 생성 +- `PUT /api/numbering-rules/:ruleId` - 규칙 수정 +- `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제 +- `POST /api/numbering-rules/:ruleId/generate` - 코드 생성 + +## 버전 정보 + +- **버전**: 1.0.0 +- **작성일**: 2025-11-04 +- **작성자**: 개발팀 + diff --git a/frontend/lib/registry/components/numbering-rule/config.ts b/frontend/lib/registry/components/numbering-rule/config.ts new file mode 100644 index 00000000..87e5c996 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/config.ts @@ -0,0 +1,15 @@ +/** + * 채번 규칙 컴포넌트 기본 설정 + */ + +import { NumberingRuleComponentConfig } from "./types"; + +export const defaultConfig: NumberingRuleComponentConfig = { + maxRules: 6, + readonly: false, + showPreview: true, + showRuleList: true, + enableReorder: false, + cardLayout: "vertical", +}; + diff --git a/frontend/lib/registry/components/numbering-rule/index.ts b/frontend/lib/registry/components/numbering-rule/index.ts new file mode 100644 index 00000000..6399ab2a --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/index.ts @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { NumberingRuleWrapper } from "./NumberingRuleComponent"; +import { NumberingRuleConfigPanel } from "./NumberingRuleConfigPanel"; +import { defaultConfig } from "./config"; + +/** + * 채번 규칙 컴포넌트 정의 + * 코드 자동 채번 규칙을 설정하고 관리하는 관리자 전용 컴포넌트 + */ +export const NumberingRuleDefinition = createComponentDefinition({ + id: "numbering-rule", + name: "코드 채번 규칙", + nameEng: "Numbering Rule Component", + description: "코드 자동 채번 규칙을 설정하고 관리하는 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "component", + component: NumberingRuleWrapper, + defaultConfig: defaultConfig, + defaultSize: { + width: 1200, + height: 800, + gridColumnSpan: "12", + }, + configPanel: NumberingRuleConfigPanel, + icon: "Hash", + tags: ["코드", "채번", "규칙", "표시", "자동생성"], + version: "1.0.0", + author: "개발팀", + documentation: "코드 자동 채번 규칙을 설정합니다. 접두사, 날짜, 순번 등을 조합하여 고유한 코드를 생성할 수 있습니다.", +}); + +// 타입 내보내기 +export type { NumberingRuleComponentConfig } from "./types"; + +// 컴포넌트 내보내기 +export { NumberingRuleComponent } from "./NumberingRuleComponent"; +export { NumberingRuleRenderer } from "./NumberingRuleRenderer"; + diff --git a/frontend/lib/registry/components/numbering-rule/types.ts b/frontend/lib/registry/components/numbering-rule/types.ts new file mode 100644 index 00000000..43def2cb --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/types.ts @@ -0,0 +1,15 @@ +/** + * 채번 규칙 컴포넌트 타입 정의 + */ + +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +export interface NumberingRuleComponentConfig { + ruleConfig?: NumberingRuleConfig; + maxRules?: number; + readonly?: boolean; + showPreview?: boolean; + showRuleList?: boolean; + enableReorder?: boolean; + cardLayout?: "vertical" | "horizontal"; +} diff --git a/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx b/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx index 41c91032..8d196db7 100644 --- a/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx +++ b/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx @@ -47,6 +47,8 @@ export const RadioBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx b/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx index c23cf50b..22c364fb 100644 --- a/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx +++ b/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx @@ -39,6 +39,8 @@ export const SliderBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 9ee27c36..46c03aef 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -19,9 +19,12 @@ import { TableIcon, Settings, X, + Layers, + ChevronDown, } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; +import { toast } from "sonner"; import { Dialog, DialogContent, @@ -35,6 +38,18 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { CardModeRenderer } from "./CardModeRenderer"; +// ======================================== +// 인터페이스 +// ======================================== + +// 그룹화된 데이터 인터페이스 +interface GroupedData { + groupKey: string; + groupValues: Record; + items: any[]; + count: number; +} + // ======================================== // 캐시 및 유틸리티 // ======================================== @@ -219,6 +234,8 @@ export const TableListComponent: React.FC = ({ backgroundColor: "hsl(var(--background))", overflow: "hidden", ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // ======================================== @@ -244,12 +261,21 @@ export const TableListComponent: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [isDragging, setIsDragging] = useState(false); const [draggedRowIndex, setDraggedRowIndex] = useState(null); + const [columnWidths, setColumnWidths] = useState>({}); + const columnRefs = useRef>({}); const [isAllSelected, setIsAllSelected] = useState(false); + const hasInitializedWidths = useRef(false); + const isResizing = useRef(false); // 필터 설정 관련 상태 const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); const [visibleFilterColumns, setVisibleFilterColumns] = useState>(new Set()); + // 그룹 설정 관련 상태 + const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); + const [groupByColumns, setGroupByColumns] = useState([]); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, { enableBatchLoading: true, preloadCommonCodes: true, @@ -260,7 +286,7 @@ export const TableListComponent: React.FC = ({ // 컬럼 라벨 가져오기 // ======================================== - const fetchColumnLabels = async () => { + const fetchColumnLabels = useCallback(async () => { if (!tableConfig.selectedTable) return; try { @@ -284,20 +310,29 @@ export const TableListComponent: React.FC = ({ } const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); + + // 컬럼 입력 타입 정보 가져오기 + const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); + const inputTypeMap: Record = {}; + inputTypes.forEach((col: any) => { + inputTypeMap[col.columnName] = col.inputType; + }); tableColumnCache.set(cacheKey, { columns, + inputTypes, timestamp: Date.now(), }); const labels: Record = {}; - const meta: Record = {}; + const meta: Record = {}; columns.forEach((col: any) => { labels[col.columnName] = col.displayName || col.comment || col.columnName; meta[col.columnName] = { webType: col.webType, codeCategory: col.codeCategory, + inputType: inputTypeMap[col.columnName], }; }); @@ -306,13 +341,13 @@ export const TableListComponent: React.FC = ({ } catch (error) { console.error("컬럼 라벨 가져오기 실패:", error); } - }; + }, [tableConfig.selectedTable]); // ======================================== // 테이블 라벨 가져오기 // ======================================== - const fetchTableLabel = async () => { + const fetchTableLabel = useCallback(async () => { if (!tableConfig.selectedTable) return; try { @@ -341,7 +376,7 @@ export const TableListComponent: React.FC = ({ } catch (error) { console.error("테이블 라벨 가져오기 실패:", error); } - }; + }, [tableConfig.selectedTable]); // ======================================== // 데이터 가져오기 @@ -498,7 +533,10 @@ export const TableListComponent: React.FC = ({ onSelectedRowsChange(Array.from(newSelectedRows), selectedRowsData); } if (onFormDataChange) { - onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData }); + onFormDataChange({ + selectedRows: Array.from(newSelectedRows), + selectedRowsData, + }); } const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index))); @@ -516,7 +554,10 @@ export const TableListComponent: React.FC = ({ onSelectedRowsChange(Array.from(newSelectedRows), data); } if (onFormDataChange) { - onFormDataChange({ selectedRows: Array.from(newSelectedRows), selectedRowsData: data }); + onFormDataChange({ + selectedRows: Array.from(newSelectedRows), + selectedRowsData: data, + }); } } else { setSelectedRows(new Set()); @@ -642,12 +683,46 @@ export const TableListComponent: React.FC = ({ } const meta = columnMeta[column.columnName]; - if (meta?.webType && meta?.codeCategory) { - const convertedValue = optimizedConvertCode(value, meta.codeCategory); - if (convertedValue !== value) return convertedValue; + + // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) + const inputType = meta?.inputType || column.inputType; + + // 코드 타입: 코드 값 → 코드명 변환 + if (inputType === "code" && meta?.codeCategory && value) { + try { + // optimizedConvertCode(categoryCode, codeValue) 순서 주의! + const convertedValue = optimizedConvertCode(meta.codeCategory, value); + // 변환에 성공했으면 변환된 코드명 반환 + if (convertedValue && convertedValue !== value) { + return convertedValue; + } + } catch (error) { + console.error(`코드 변환 실패: ${column.columnName}, 카테고리: ${meta.codeCategory}, 값: ${value}`, error); + } + // 변환 실패 시 원본 코드 값 반환 + return String(value); + } + + // 숫자 타입 포맷팅 + if (inputType === "number" || inputType === "decimal") { + if (value !== null && value !== undefined && value !== "") { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue)) { + return numValue.toLocaleString("ko-KR"); + } + } + return String(value); } switch (column.format) { + case "number": + if (value !== null && value !== undefined && value !== "") { + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (!isNaN(numValue)) { + return numValue.toLocaleString("ko-KR"); + } + } + return String(value); case "date": if (value) { try { @@ -681,9 +756,15 @@ export const TableListComponent: React.FC = ({ return `tableList_filterSettings_${tableConfig.selectedTable}`; }, [tableConfig.selectedTable]); + // 그룹 설정 localStorage 키 생성 + const groupSettingKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `tableList_groupSettings_${tableConfig.selectedTable}`; + }, [tableConfig.selectedTable]); + // 저장된 필터 설정 불러오기 useEffect(() => { - if (!filterSettingKey) return; + if (!filterSettingKey || visibleColumns.length === 0) return; try { const saved = localStorage.getItem(filterSettingKey); @@ -691,17 +772,14 @@ export const TableListComponent: React.FC = ({ const savedFilters = JSON.parse(saved); setVisibleFilterColumns(new Set(savedFilters)); } else { - // 초기값: 모든 필터 표시 - const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName); - setVisibleFilterColumns(new Set(allFilters)); + // 초기값: 빈 Set (아무것도 선택 안 함) + setVisibleFilterColumns(new Set()); } } catch (error) { console.error("필터 설정 불러오기 실패:", error); - // 기본값으로 모든 필터 표시 - const allFilters = (tableConfig.filter?.filters || []).map((f) => f.columnName); - setVisibleFilterColumns(new Set(allFilters)); + setVisibleFilterColumns(new Set()); } - }, [filterSettingKey, tableConfig.filter?.filters]); + }, [filterSettingKey, visibleColumns]); // 필터 설정 저장 const saveFilterSettings = useCallback(() => { @@ -710,12 +788,17 @@ export const TableListComponent: React.FC = ({ try { localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(visibleFilterColumns))); setIsFilterSettingOpen(false); + toast.success("검색 필터 설정이 저장되었습니다"); + + // 검색 값 초기화 + setSearchValues({}); } catch (error) { console.error("필터 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); } }, [filterSettingKey, visibleFilterColumns]); - // 필터 토글 + // 필터 컬럼 토글 const toggleFilterVisibility = useCallback((columnName: string) => { setVisibleFilterColumns((prev) => { const newSet = new Set(prev); @@ -728,15 +811,134 @@ export const TableListComponent: React.FC = ({ }); }, []); - // 표시할 필터 목록 + // 전체 선택/해제 + const toggleAllFilters = useCallback(() => { + const filterableColumns = visibleColumns.filter((col) => col.columnName !== "__checkbox__"); + const columnNames = filterableColumns.map((col) => col.columnName); + + if (visibleFilterColumns.size === columnNames.length) { + // 전체 해제 + setVisibleFilterColumns(new Set()); + } else { + // 전체 선택 + setVisibleFilterColumns(new Set(columnNames)); + } + }, [visibleFilterColumns, visibleColumns]); + + // 표시할 필터 목록 (선택된 컬럼만) const activeFilters = useMemo(() => { - return (tableConfig.filter?.filters || []).filter((f) => visibleFilterColumns.has(f.columnName)); - }, [tableConfig.filter?.filters, visibleFilterColumns]); + return visibleColumns + .filter((col) => col.columnName !== "__checkbox__" && visibleFilterColumns.has(col.columnName)) + .map((col) => ({ + columnName: col.columnName, + label: columnLabels[col.columnName] || col.displayName || col.columnName, + type: col.format || "text", + })); + }, [visibleColumns, visibleFilterColumns, columnLabels]); + + // 그룹 설정 저장 + const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } + }, [groupSettingKey, groupByColumns]); + + // 그룹 컬럼 토글 + const toggleGroupColumn = useCallback((columnName: string) => { + setGroupByColumns((prev) => { + if (prev.includes(columnName)) { + return prev.filter((col) => col !== columnName); + } else { + return [...prev, columnName]; + } + }); + }, []); + + // 그룹 펼치기/접기 토글 + const toggleGroupCollapse = useCallback((groupKey: string) => { + setCollapsedGroups((prev) => { + const newSet = new Set(prev); + if (newSet.has(groupKey)) { + newSet.delete(groupKey); + } else { + newSet.add(groupKey); + } + return newSet; + }); + }, []); + + // 그룹 해제 + const clearGrouping = useCallback(() => { + setGroupByColumns([]); + setCollapsedGroups(new Set()); + if (groupSettingKey) { + localStorage.removeItem(groupSettingKey); + } + toast.success("그룹이 해제되었습니다"); + }, [groupSettingKey]); + + // 데이터 그룹화 + const groupedData = useMemo((): GroupedData[] => { + if (groupByColumns.length === 0 || data.length === 0) return []; + + const grouped = new Map(); + + data.forEach((item) => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupByColumns.map((col) => { + const value = item[col]; + const label = columnLabels[col] || col; + return `${label}:${value !== null && value !== undefined ? value : "-"}`; + }); + const groupKey = keyParts.join(" > "); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupByColumns.forEach((col) => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); + }, [data, groupByColumns, columnLabels]); + + // 저장된 그룹 설정 불러오기 + useEffect(() => { + if (!groupSettingKey || visibleColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + setGroupByColumns(savedGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + } + }, [groupSettingKey, visibleColumns]); useEffect(() => { fetchColumnLabels(); fetchTableLabel(); - }, [tableConfig.selectedTable]); + }, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]); useEffect(() => { if (!isDesignMode && tableConfig.selectedTable) { @@ -751,6 +953,7 @@ export const TableListComponent: React.FC = ({ searchTerm, refreshKey, isDesignMode, + fetchTableDataDebounced, ]); useEffect(() => { @@ -763,6 +966,38 @@ export const TableListComponent: React.FC = ({ } }, [tableConfig.refreshInterval, isDesignMode]); + // 초기 컬럼 너비 측정 (한 번만) + useEffect(() => { + if (!hasInitializedWidths.current && visibleColumns.length > 0) { + // 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정 + const timer = setTimeout(() => { + const newWidths: Record = {}; + let hasAnyWidth = false; + + visibleColumns.forEach((column) => { + // 체크박스 컬럼은 제외 (고정 48px) + if (column.columnName === "__checkbox__") return; + + const thElement = columnRefs.current[column.columnName]; + if (thElement) { + const measuredWidth = thElement.offsetWidth; + if (measuredWidth > 0) { + newWidths[column.columnName] = measuredWidth; + hasAnyWidth = true; + } + } + }); + + if (hasAnyWidth) { + setColumnWidths(newWidths); + hasInitializedWidths.current = true; + } + }, 100); + + return () => clearTimeout(timer); + } + }, [visibleColumns]); + // ======================================== // 페이지네이션 JSX // ======================================== @@ -872,14 +1107,6 @@ export const TableListComponent: React.FC = ({ if (tableConfig.stickyHeader && !isDesignMode) { return (
- {tableConfig.showHeader && ( -
-

- {tableConfig.title || tableLabel || finalSelectedTable} -

-
- )} - {tableConfig.filter?.enabled && (
@@ -892,20 +1119,57 @@ export const TableListComponent: React.FC = ({ onClear={handleClearAdvancedFilters} />
- +
+ + +
)} -
+ {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+ +
+
+ )} + +
= ({ return ( <>
- {/* 헤더 */} - {tableConfig.showHeader && ( -
-

- {tableConfig.title || tableLabel || finalSelectedTable} -

-
- )} - {/* 필터 */} {tableConfig.filter?.enabled && (
@@ -957,21 +1212,58 @@ export const TableListComponent: React.FC = ({ onClear={handleClearAdvancedFilters} />
- + +
+
+
+ )} + + {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+
+ 그룹: +
+ {groupByColumns.map((col, idx) => ( + + {idx > 0 && } + + {columnLabels[col] || col} + + + ))} +
+ + +
)} {/* 테이블 컨테이너 */} -
+
{/* 스크롤 영역 */}
= ({ style={{ borderCollapse: "collapse", width: "100%", + tableLayout: "fixed", }} > {/* 헤더 (sticky) */} - - {visibleColumns.map((column) => ( - column.sortable && handleSort(column.columnName)} - > - {column.columnName === "__checkbox__" ? ( - renderCheckboxHeader() - ) : ( -
- {columnLabels[column.columnName] || column.displayName} - {column.sortable && sortColumn === column.columnName && ( - {sortDirection === "asc" ? "↑" : "↓"} - )} -
- )} - - ))} + + {visibleColumns.map((column, columnIndex) => { + const columnWidth = columnWidths[column.columnName]; + + return ( + (columnRefs.current[column.columnName] = el)} + className={cn( + "relative h-10 text-xs font-bold text-foreground/90 overflow-hidden text-ellipsis whitespace-nowrap select-none sm:h-12 sm:text-sm", + column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-6 sm:py-3", + column.sortable && "cursor-pointer hover:bg-muted/70 transition-colors" + )} + style={{ + textAlign: column.columnName === "__checkbox__" ? "center" : "center", + width: column.columnName === "__checkbox__" ? '48px' : (columnWidth ? `${columnWidth}px` : undefined), + minWidth: column.columnName === "__checkbox__" ? '48px' : undefined, + maxWidth: column.columnName === "__checkbox__" ? '48px' : undefined, + userSelect: 'none' + }} + onClick={() => { + if (isResizing.current) return; + if (column.sortable) handleSort(column.columnName); + }} + > + {column.columnName === "__checkbox__" ? ( + renderCheckboxHeader() + ) : ( +
+ {columnLabels[column.columnName] || column.displayName} + {column.sortable && sortColumn === column.columnName && ( + {sortDirection === "asc" ? "↑" : "↓"} + )} +
+ )} + {/* 리사이즈 핸들 (체크박스 제외) */} + {columnIndex < visibleColumns.length - 1 && column.columnName !== "__checkbox__" && ( +
e.stopPropagation()} // 정렬 클릭 방지 + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + + const thElement = columnRefs.current[column.columnName]; + if (!thElement) return; + + isResizing.current = true; + + const startX = e.clientX; + const startWidth = columnWidth || thElement.offsetWidth; + + // 드래그 중 텍스트 선택 방지 + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const handleMouseMove = (moveEvent: MouseEvent) => { + moveEvent.preventDefault(); + + const diff = moveEvent.clientX - startX; + const newWidth = Math.max(80, startWidth + diff); + + // 직접 DOM 스타일 변경 (리렌더링 없음) + if (thElement) { + thElement.style.width = `${newWidth}px`; + } + }; + + const handleMouseUp = () => { + // 최종 너비를 state에 저장 + if (thElement) { + const finalWidth = Math.max(80, thElement.offsetWidth); + setColumnWidths(prev => ({ ...prev, [column.columnName]: finalWidth })); + } + + // 텍스트 선택 복원 + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + + // 약간의 지연 후 리사이즈 플래그 해제 (클릭 이벤트가 먼저 처리되지 않도록) + setTimeout(() => { + isResizing.current = false; + }, 100); + + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }} + /> + )} + + ); + })} @@ -1049,7 +1413,81 @@ export const TableListComponent: React.FC = ({
+ ) : groupByColumns.length > 0 && groupedData.length > 0 ? ( + // 그룹화된 렌더링 + groupedData.map((group) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + return ( + + {/* 그룹 헤더 */} + + +
toggleGroupCollapse(group.groupKey)} + > + {isCollapsed ? ( + + ) : ( + + )} + {group.groupKey} + ({group.count}건) +
+ + + {/* 그룹 데이터 */} + {!isCollapsed && + group.items.map((row, index) => ( + handleRowDragStart(e, row, index)} + onDragEnd={handleRowDragEnd} + className={cn( + "h-14 border-b transition-colors bg-background hover:bg-muted/50 cursor-pointer sm:h-16" + )} + onClick={() => handleRowClick(row)} + > + {visibleColumns.map((column) => { + const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; + const cellValue = row[mappedColumnName]; + + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + + return ( + + {column.columnName === "__checkbox__" + ? renderCheckboxCell(row, index) + : formatCellValue(cellValue, column, row)} + + ); + })} + + ))} +
+ ); + }) ) : ( + // 일반 렌더링 (그룹 없음) data.map((row, index) => ( = ({ const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const cellValue = row[mappedColumnName]; + const meta = columnMeta[column.columnName]; + const inputType = meta?.inputType || column.inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + return ( {column.columnName === "__checkbox__" @@ -1100,26 +1545,63 @@ export const TableListComponent: React.FC = ({ 검색 필터 설정 - 표시할 검색 필터를 선택하세요. 선택하지 않은 필터는 숨겨집니다. + 검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다. -
- {(tableConfig.filter?.filters || []).map((filter) => ( -
- toggleFilterVisibility(filter.columnName)} - /> - -
- ))} +
+ {/* 전체 선택/해제 */} +
+ col.columnName !== "__checkbox__").length && + visibleColumns.filter((col) => col.columnName !== "__checkbox__").length > 0 + } + onCheckedChange={toggleAllFilters} + /> + + + {visibleFilterColumns.size} / {visibleColumns.filter((col) => col.columnName !== "__checkbox__").length} + 개 + +
+ + {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleFilterVisibility(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 컬럼 개수 안내 */} +
+ {visibleFilterColumns.size === 0 ? ( + 검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요 + ) : ( + + 총 {visibleFilterColumns.size}개의 검색 필터가 + 표시됩니다 + + )} +
@@ -1136,6 +1618,68 @@ export const TableListComponent: React.FC = ({ + + {/* 그룹 설정 다이얼로그 */} + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹:{" "} + + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
); }; diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index 3b545d53..3bcefc22 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -117,7 +117,9 @@ export const TextInputComponent: React.FC = ({ height: "100%", ...component.style, ...style, - // 숨김 기능: 편집 모드에서만 연하게 표시 + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", + // 숨김 기능: 편집 모드에서만 연하게 표시 ...(isHidden && isDesignMode && { opacity: 0.4, diff --git a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx index e842ff34..eea2f113 100644 --- a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx +++ b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx @@ -39,6 +39,8 @@ export const TextareaBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx b/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx index a3a3786e..4d9fcbe2 100644 --- a/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx +++ b/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx @@ -39,6 +39,8 @@ export const ToggleSwitchComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 4c376b09..753deea5 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -17,7 +17,10 @@ export type ButtonActionType = | "navigate" // 페이지 이동 | "modal" // 모달 열기 | "control" // 제어 흐름 - | "view_table_history"; // 테이블 이력 보기 + | "view_table_history" // 테이블 이력 보기 + | "excel_download" // 엑셀 다운로드 + | "excel_upload" // 엑셀 업로드 + | "barcode_scan"; // 바코드 스캔 /** * 버튼 액션 설정 @@ -56,6 +59,20 @@ export interface ButtonActionConfig { historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스 historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항) historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name) + + // 엑셀 다운로드 관련 + excelFileName?: string; // 다운로드할 파일명 (기본: 테이블명_날짜.xlsx) + excelSheetName?: string; // 시트명 (기본: "Sheet1") + excelIncludeHeaders?: boolean; // 헤더 포함 여부 (기본: true) + + // 엑셀 업로드 관련 + excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드 + excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼 + + // 바코드 스캔 관련 + barcodeTargetField?: string; // 스캔 결과를 입력할 필드명 + barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all") + barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부 } /** @@ -121,6 +138,15 @@ export class ButtonActionExecutor { case "view_table_history": return this.handleViewTableHistory(config, context); + case "excel_download": + return await this.handleExcelDownload(config, context); + + case "excel_upload": + return await this.handleExcelUpload(config, context); + + case "barcode_scan": + return await this.handleBarcodeScan(config, context); + default: console.warn(`지원되지 않는 액션 타입: ${config.type}`); return false; @@ -203,15 +229,29 @@ export class ButtonActionExecutor { // INSERT 처리 // 🆕 자동으로 작성자 정보 추가 - const writerValue = context.userId || context.userName || "unknown"; + if (!context.userId) { + throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요."); + } + + const writerValue = context.userId; const companyCodeValue = context.companyCode || ""; + console.log("👤 [buttonActions] 사용자 정보:", { + userId: context.userId, + userName: context.userName, + companyCode: context.companyCode, // ✅ 회사 코드 + formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값 + formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값 + defaultWriterValue: writerValue, + companyCodeValue, // ✅ 최종 회사 코드 값 + }); + const dataWithUserInfo = { ...formData, - writer: writerValue, - created_by: writerValue, - updated_by: writerValue, - company_code: companyCodeValue, + writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId + created_by: writerValue, // created_by는 항상 로그인한 사람 + updated_by: writerValue, // updated_by는 항상 로그인한 사람 + company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode }; saveResult = await DynamicFormApi.saveFormData({ @@ -234,9 +274,13 @@ export class ButtonActionExecutor { throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); } - // 테이블과 플로우 모두 새로고침 + // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) context.onRefresh?.(); context.onFlowRefresh?.(); + + // 저장 성공 후 EditModal 닫기 이벤트 발생 + window.dispatchEvent(new CustomEvent("closeEditModal")); + return true; } catch (error) { console.error("저장 오류:", error); @@ -1628,6 +1672,226 @@ export class ButtonActionExecutor { } } + /** + * 엑셀 다운로드 액션 처리 + */ + private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("📥 엑셀 다운로드 시작:", { config, context }); + + // 동적 import로 엑셀 유틸리티 로드 + const { exportToExcel } = await import("@/lib/utils/excelExport"); + + let dataToExport: any[] = []; + + // 1순위: 선택된 행 데이터 + if (context.selectedRowsData && context.selectedRowsData.length > 0) { + dataToExport = context.selectedRowsData; + console.log("✅ 선택된 행 데이터 사용:", dataToExport.length); + } + // 2순위: 테이블 전체 데이터 (API 호출) + else if (context.tableName) { + console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName); + try { + const { dynamicFormApi } = await import("@/lib/api/dynamicForm"); + const response = await dynamicFormApi.getTableData(context.tableName, { + page: 1, + pageSize: 10000, // 최대 10,000개 행 + sortBy: "id", // 기본 정렬: id 컬럼 + sortOrder: "asc", // 오름차순 + }); + + console.log("📦 API 응답 구조:", { + response, + responseSuccess: response.success, + responseData: response.data, + responseDataType: typeof response.data, + responseDataIsArray: Array.isArray(response.data), + responseDataLength: Array.isArray(response.data) ? response.data.length : "N/A", + }); + + if (response.success && response.data) { + dataToExport = response.data; + console.log("✅ 테이블 전체 데이터 조회 완료:", { + count: dataToExport.length, + firstRow: dataToExport[0], + }); + } else { + console.error("❌ API 응답에 데이터가 없습니다:", response); + } + } catch (error) { + console.error("❌ 테이블 데이터 조회 실패:", error); + } + } + // 4순위: 폼 데이터 + else if (context.formData && Object.keys(context.formData).length > 0) { + dataToExport = [context.formData]; + console.log("✅ 폼 데이터 사용:", dataToExport); + } + + console.log("📊 최종 다운로드 데이터:", { + selectedRowsData: context.selectedRowsData, + selectedRowsLength: context.selectedRowsData?.length, + formData: context.formData, + tableName: context.tableName, + dataToExport, + dataToExportType: typeof dataToExport, + dataToExportIsArray: Array.isArray(dataToExport), + dataToExportLength: Array.isArray(dataToExport) ? dataToExport.length : "N/A", + }); + + // 배열이 아니면 배열로 변환 + if (!Array.isArray(dataToExport)) { + console.warn("⚠️ dataToExport가 배열이 아닙니다. 변환 시도:", dataToExport); + + // 객체인 경우 배열로 감싸기 + if (typeof dataToExport === "object" && dataToExport !== null) { + dataToExport = [dataToExport]; + } else { + toast.error("다운로드할 데이터 형식이 올바르지 않습니다."); + return false; + } + } + + if (dataToExport.length === 0) { + toast.error("다운로드할 데이터가 없습니다."); + return false; + } + + // 파일명 생성 + const fileName = config.excelFileName || `${context.tableName || "데이터"}_${new Date().toISOString().split("T")[0]}.xlsx`; + const sheetName = config.excelSheetName || "Sheet1"; + const includeHeaders = config.excelIncludeHeaders !== false; + + console.log("📥 엑셀 다운로드 실행:", { + fileName, + sheetName, + includeHeaders, + dataCount: dataToExport.length, + firstRow: dataToExport[0], + }); + + // 엑셀 다운로드 실행 + await exportToExcel(dataToExport, fileName, sheetName, includeHeaders); + + toast.success(config.successMessage || "엑셀 파일이 다운로드되었습니다."); + return true; + } catch (error) { + console.error("❌ 엑셀 다운로드 실패:", error); + toast.error(config.errorMessage || "엑셀 다운로드 중 오류가 발생했습니다."); + return false; + } + } + + /** + * 엑셀 업로드 액션 처리 + */ + private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("📤 엑셀 업로드 모달 열기:", { config, context }); + + // 동적 import로 모달 컴포넌트 로드 + const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal"); + const { createRoot } = await import("react-dom/client"); + + // 모달 컨테이너 생성 + const modalContainer = document.createElement("div"); + document.body.appendChild(modalContainer); + + const root = createRoot(modalContainer); + + const closeModal = () => { + root.unmount(); + document.body.removeChild(modalContainer); + }; + + root.render( + React.createElement(ExcelUploadModal, { + open: true, + onOpenChange: (open: boolean) => { + if (!open) closeModal(); + }, + tableName: context.tableName || "", + uploadMode: config.excelUploadMode || "insert", + keyColumn: config.excelKeyColumn, + onSuccess: () => { + // 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨 + context.onRefresh?.(); + closeModal(); + }, + }), + ); + + return true; + } catch (error) { + console.error("❌ 엑셀 업로드 모달 열기 실패:", error); + toast.error(config.errorMessage || "엑셀 업로드 중 오류가 발생했습니다."); + return false; + } + } + + /** + * 바코드 스캔 액션 처리 + */ + private static async handleBarcodeScan(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("📷 바코드 스캔 모달 열기:", { config, context }); + + // 동적 import로 모달 컴포넌트 로드 + const { BarcodeScanModal } = await import("@/components/common/BarcodeScanModal"); + const { createRoot } = await import("react-dom/client"); + + // 모달 컨테이너 생성 + const modalContainer = document.createElement("div"); + document.body.appendChild(modalContainer); + + const root = createRoot(modalContainer); + + const closeModal = () => { + root.unmount(); + document.body.removeChild(modalContainer); + }; + + root.render( + React.createElement(BarcodeScanModal, { + open: true, + onOpenChange: (open: boolean) => { + if (!open) closeModal(); + }, + targetField: config.barcodeTargetField, + barcodeFormat: config.barcodeFormat || "all", + autoSubmit: config.barcodeAutoSubmit || false, + onScanSuccess: (barcode: string) => { + console.log("✅ 바코드 스캔 성공:", barcode); + + // 대상 필드에 값 입력 + if (config.barcodeTargetField && context.onFormDataChange) { + context.onFormDataChange({ + ...context.formData, + [config.barcodeTargetField]: barcode, + }); + } + + toast.success(`바코드 스캔 완료: ${barcode}`); + + // 자동 제출 옵션이 켜져있으면 저장 + if (config.barcodeAutoSubmit) { + this.handleSave(config, context); + } + + closeModal(); + }, + }), + ); + + return true; + } catch (error) { + console.error("❌ 바코드 스캔 모달 열기 실패:", error); + toast.error("바코드 스캔 중 오류가 발생했습니다."); + return false; + } + } + /** * 폼 데이터 유효성 검사 */ @@ -1699,4 +1963,22 @@ export const DEFAULT_BUTTON_ACTIONS: Record[], + fileName: string = "export.xlsx", + sheetName: string = "Sheet1", + includeHeaders: boolean = true +): Promise { + try { + console.log("📥 엑셀 내보내기 시작:", { + dataCount: data.length, + fileName, + sheetName, + includeHeaders, + }); + + if (data.length === 0) { + throw new Error("내보낼 데이터가 없습니다."); + } + + // 워크북 생성 + const workbook = XLSX.utils.book_new(); + + // 데이터를 워크시트로 변환 + const worksheet = XLSX.utils.json_to_sheet(data, { + header: includeHeaders ? undefined : [], + skipHeader: !includeHeaders, + }); + + // 컬럼 너비 자동 조정 + const columnWidths = autoSizeColumns(data); + worksheet["!cols"] = columnWidths; + + // 워크시트를 워크북에 추가 + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + + // 파일 다운로드 + XLSX.writeFile(workbook, fileName); + + console.log("✅ 엑셀 내보내기 완료:", fileName); + } catch (error) { + console.error("❌ 엑셀 내보내기 실패:", error); + throw error; + } +} + +/** + * 컬럼 너비 자동 조정 + */ +function autoSizeColumns(data: Record[]): Array<{ wch: number }> { + if (data.length === 0) return []; + + const keys = Object.keys(data[0]); + const columnWidths: Array<{ wch: number }> = []; + + keys.forEach((key) => { + // 헤더 길이 + let maxWidth = key.length; + + // 데이터 길이 확인 + data.forEach((row) => { + const value = row[key]; + const valueLength = value ? String(value).length : 0; + maxWidth = Math.max(maxWidth, valueLength); + }); + + // 최소 10, 최대 50으로 제한 + columnWidths.push({ wch: Math.min(Math.max(maxWidth, 10), 50) }); + }); + + return columnWidths; +} + +/** + * 엑셀 파일을 읽어서 JSON 데이터로 변환 + * @param file 읽을 파일 + * @param sheetName 읽을 시트명 (기본: 첫 번째 시트) + * @returns JSON 데이터 배열 + */ +export async function importFromExcel( + file: File, + sheetName?: string +): Promise[]> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result; + if (!data) { + reject(new Error("파일을 읽을 수 없습니다.")); + return; + } + + // 워크북 읽기 + const workbook = XLSX.read(data, { type: "binary" }); + + // 시트 선택 (지정된 시트 또는 첫 번째 시트) + const targetSheetName = sheetName || workbook.SheetNames[0]; + const worksheet = workbook.Sheets[targetSheetName]; + + if (!worksheet) { + reject(new Error(`시트 "${targetSheetName}"를 찾을 수 없습니다.`)); + return; + } + + // JSON으로 변환 + const jsonData = XLSX.utils.sheet_to_json(worksheet); + + console.log("✅ 엑셀 가져오기 완료:", { + sheetName: targetSheetName, + rowCount: jsonData.length, + }); + + resolve(jsonData as Record[]); + } catch (error) { + console.error("❌ 엑셀 가져오기 실패:", error); + reject(error); + } + }; + + reader.onerror = () => { + reject(new Error("파일 읽기 중 오류가 발생했습니다.")); + }; + + reader.readAsBinaryString(file); + }); +} + +/** + * 엑셀 파일의 시트 목록 가져오기 + */ +export async function getExcelSheetNames(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result; + if (!data) { + reject(new Error("파일을 읽을 수 없습니다.")); + return; + } + + const workbook = XLSX.read(data, { type: "binary" }); + resolve(workbook.SheetNames); + } catch (error) { + console.error("❌ 시트 목록 가져오기 실패:", error); + reject(error); + } + }; + + reader.onerror = () => { + reject(new Error("파일 읽기 중 오류가 발생했습니다.")); + }; + + reader.readAsBinaryString(file); + }); +} + diff --git a/frontend/lib/validation/businessNumber.ts b/frontend/lib/validation/businessNumber.ts new file mode 100644 index 00000000..81b133d2 --- /dev/null +++ b/frontend/lib/validation/businessNumber.ts @@ -0,0 +1,74 @@ +/** + * 사업자등록번호 유효성 검사 유틸리티 + */ + +/** + * 사업자등록번호 포맷 검증 (000-00-00000 형식) + */ +export function validateBusinessNumberFormat(value: string): boolean { + if (!value || value.trim() === "") { + return false; + } + + // 하이픈 제거 + const cleaned = value.replace(/-/g, ""); + + // 숫자 10자리인지 확인 + if (!/^\d{10}$/.test(cleaned)) { + return false; + } + + return true; +} + +/** + * 사업자등록번호 종합 검증 (포맷만 검사) + * 실제 국세청 검증은 백엔드에서 API 호출로 처리하는 것을 권장 + */ +export function validateBusinessNumber(value: string): { + isValid: boolean; + message: string; +} { + if (!value || value.trim() === "") { + return { + isValid: false, + message: "사업자등록번호를 입력해주세요.", + }; + } + + if (!validateBusinessNumberFormat(value)) { + return { + isValid: false, + message: "사업자등록번호는 10자리 숫자여야 합니다.", + }; + } + + // 포맷만 검증하고 통과 + return { + isValid: true, + message: "", + }; +} + +/** + * 사업자등록번호 포맷팅 (자동 하이픈 추가) + */ +export function formatBusinessNumber(value: string): string { + if (!value) return ""; + + // 숫자만 추출 + const cleaned = value.replace(/\D/g, ""); + + // 최대 10자리까지만 + const limited = cleaned.slice(0, 10); + + // 하이픈 추가 (000-00-00000) + if (limited.length <= 3) { + return limited; + } else if (limited.length <= 5) { + return `${limited.slice(0, 3)}-${limited.slice(3)}`; + } else { + return `${limited.slice(0, 3)}-${limited.slice(3, 5)}-${limited.slice(5)}`; + } +} + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5458073c..08f030e2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -44,6 +44,7 @@ "@types/react-window": "^1.8.8", "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", + "@zxing/library": "^0.21.3", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -69,6 +70,7 @@ "react-hot-toast": "^2.6.0", "react-leaflet": "^5.0.0", "react-resizable-panels": "^3.0.6", + "react-webcam": "^7.2.0", "react-window": "^2.1.0", "reactflow": "^11.11.4", "recharts": "^3.2.1", @@ -100,6 +102,12 @@ "typescript": "^5" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.19", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.19.tgz", + "integrity": "sha512-Pp2gAQXPZ2o7lt4j0IMwNRXqQ3pagxtDj5wctL5U2Lz4oV0ocDNlkgx4DpxfyKav4S/bePuI+SMqcBSUHLy9kg==", + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -127,9 +135,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.6.2.tgz", - "integrity": "sha512-+AG0jN9HTwfDLBhjhX1FKi6zlIAc/YGgEHlN/OMaHD1pOPFsC5CpYQpLkPX0aFjyaVmoq9330cQDCU4qnSL1qA==", + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", + "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", "license": "MIT", "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", @@ -246,9 +254,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", - "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.15.tgz", + "integrity": "sha512-q0p6zkVq2lJnmzZVPR33doA51G7YOja+FBvRdp5ISIthL0MtFCgYHHhR563z9WFGxcOn0WfjSkPDJ5Qig3H3Sw==", "funding": [ { "type": "github", @@ -262,9 +270,6 @@ "license": "MIT-0", "engines": { "node": ">=18" - }, - "peerDependencies": { - "postcss": "^8.4" } }, "node_modules/@csstools/css-tokenizer": { @@ -352,9 +357,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", + "integrity": "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==", "dev": true, "license": "MIT", "optional": true, @@ -364,9 +369,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz", + "integrity": "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==", "license": "MIT", "optional": true, "dependencies": { @@ -417,9 +422,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -427,13 +432,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -442,19 +447,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -489,9 +497,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", - "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "version": "9.39.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz", + "integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==", "dev": true, "license": "MIT", "engines": { @@ -502,9 +510,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -512,13 +520,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1055,19 +1063,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1355,66 +1350,66 @@ } }, "node_modules/@prisma/config": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.2.tgz", - "integrity": "sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.18.0.tgz", + "integrity": "sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", - "effect": "3.16.12", + "effect": "3.18.4", "empathic": "2.0.0" } }, "node_modules/@prisma/debug": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz", - "integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz", + "integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==", "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.2.tgz", - "integrity": "sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz", + "integrity": "sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.16.2", - "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", - "@prisma/fetch-engine": "6.16.2", - "@prisma/get-platform": "6.16.2" + "@prisma/debug": "6.18.0", + "@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f", + "@prisma/fetch-engine": "6.18.0", + "@prisma/get-platform": "6.18.0" } }, "node_modules/@prisma/engines-version": { - "version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz", - "integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==", + "version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f.tgz", + "integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==", "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.2.tgz", - "integrity": "sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz", + "integrity": "sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.16.2", - "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", - "@prisma/get-platform": "6.16.2" + "@prisma/debug": "6.18.0", + "@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f", + "@prisma/get-platform": "6.18.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.2.tgz", - "integrity": "sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.18.0.tgz", + "integrity": "sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.16.2" + "@prisma/debug": "6.18.0" } }, "node_modules/@radix-ui/number": { @@ -2571,35 +2566,6 @@ } } }, - "node_modules/@react-three/drei/node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, "node_modules/@react-three/fiber": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", @@ -2650,41 +2616,6 @@ } } }, - "node_modules/@react-three/fiber/node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" - }, - "node_modules/@react-three/fiber/node_modules/zustand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", - "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, "node_modules/@reactflow/background": { "version": "11.3.14", "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", @@ -2700,6 +2631,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/background/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/controls": { "version": "11.2.14", "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", @@ -2715,6 +2674,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/controls/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/core": { "version": "11.11.4", "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", @@ -2736,6 +2723,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/core/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/minimap": { "version": "11.7.14", "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", @@ -2755,6 +2770,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/minimap/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/node-resizer": { "version": "2.2.14", "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", @@ -2772,6 +2815,34 @@ "react-dom": ">=17" } }, + "node_modules/@reactflow/node-resizer/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@reactflow/node-toolbar": { "version": "1.3.14", "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", @@ -2787,28 +2858,30 @@ "react-dom": ">=17" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz", - "integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==", + "node_modules/@reactflow/node-toolbar/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^10.0.3", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" }, "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" }, "peerDependenciesMeta": { - "react": { + "@types/react": { "optional": true }, - "react-redux": { + "immer": { + "optional": true + }, + "react": { "optional": true } } @@ -2821,9 +2894,9 @@ "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", - "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.1.tgz", + "integrity": "sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==", "dev": true, "license": "MIT" }, @@ -2849,54 +2922,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", - "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", + "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", - "jiti": "^2.5.1", - "lightningcss": "1.30.1", - "magic-string": "^0.30.18", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.19", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.13" + "tailwindcss": "4.1.16" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", - "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", + "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.13", - "@tailwindcss/oxide-darwin-arm64": "4.1.13", - "@tailwindcss/oxide-darwin-x64": "4.1.13", - "@tailwindcss/oxide-freebsd-x64": "4.1.13", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", - "@tailwindcss/oxide-linux-x64-musl": "4.1.13", - "@tailwindcss/oxide-wasm32-wasi": "4.1.13", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + "@tailwindcss/oxide-android-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-x64": "4.1.16", + "@tailwindcss/oxide-freebsd-x64": "4.1.16", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-x64-musl": "4.1.16", + "@tailwindcss/oxide-wasm32-wasi": "4.1.16", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", - "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz", + "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", "cpu": [ "arm64" ], @@ -2911,9 +2979,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", - "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz", + "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", "cpu": [ "arm64" ], @@ -2928,9 +2996,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", - "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz", + "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", "cpu": [ "x64" ], @@ -2945,9 +3013,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", - "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz", + "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", "cpu": [ "x64" ], @@ -2962,9 +3030,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", - "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz", + "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", "cpu": [ "arm" ], @@ -2979,9 +3047,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", - "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz", + "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", "cpu": [ "arm64" ], @@ -2996,9 +3064,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", - "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz", + "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", "cpu": [ "arm64" ], @@ -3013,9 +3081,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", - "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz", + "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", "cpu": [ "x64" ], @@ -3030,9 +3098,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", - "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz", + "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", "cpu": [ "x64" ], @@ -3047,9 +3115,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", - "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz", + "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3065,21 +3133,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.5", - "@emnapi/runtime": "^1.4.5", - "@emnapi/wasi-threads": "^1.0.4", - "@napi-rs/wasm-runtime": "^0.2.12", - "@tybys/wasm-util": "^0.10.0", - "tslib": "^2.8.0" + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", - "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz", + "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", "cpu": [ "arm64" ], @@ -3094,9 +3162,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", - "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz", + "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", "cpu": [ "x64" ], @@ -3111,23 +3179,23 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz", - "integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz", + "integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.13", - "@tailwindcss/oxide": "4.1.13", + "@tailwindcss/node": "4.1.16", + "@tailwindcss/oxide": "4.1.16", "postcss": "^8.4.41", - "tailwindcss": "4.1.13" + "tailwindcss": "4.1.16" } }, "node_modules/@tanstack/query-core": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", - "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.6.tgz", + "integrity": "sha512-AnZSLF26R8uX+tqb/ivdrwbVdGemdEDm1Q19qM6pry6eOZ6bEYiY7mWhzXT1YDIPTNEVcZ5kYP9nWjoxDLiIVw==", "license": "MIT", "funding": { "type": "github", @@ -3146,12 +3214,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz", - "integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==", + "version": "5.90.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", + "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.2" + "@tanstack/query-core": "5.90.6" }, "funding": { "type": "github", @@ -3597,21 +3665,6 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@turf/buffer/node_modules/d3-array": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", - "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", - "license": "BSD-3-Clause" - }, - "node_modules/@turf/buffer/node_modules/d3-geo": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz", - "integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-array": "1" - } - }, "node_modules/@turf/center": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@turf/center/-/center-7.2.0.tgz", @@ -5573,9 +5626,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", - "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", + "version": "20.19.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", + "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5602,22 +5655,22 @@ "optional": true }, "node_modules/@types/react": { - "version": "19.1.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", - "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", - "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-reconciler": { @@ -5686,17 +5739,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", - "integrity": "sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", + "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/type-utils": "8.44.1", - "@typescript-eslint/utils": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/type-utils": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -5710,7 +5763,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.44.1", + "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -5726,16 +5779,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", - "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", + "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4" }, "engines": { @@ -5751,14 +5804,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.1.tgz", - "integrity": "sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", + "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.44.1", - "@typescript-eslint/types": "^8.44.1", + "@typescript-eslint/tsconfig-utils": "^8.46.2", + "@typescript-eslint/types": "^8.46.2", "debug": "^4.3.4" }, "engines": { @@ -5773,14 +5826,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz", - "integrity": "sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", + "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1" + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5791,9 +5844,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz", - "integrity": "sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", + "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", "dev": true, "license": "MIT", "engines": { @@ -5808,15 +5861,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.1.tgz", - "integrity": "sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", + "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1", - "@typescript-eslint/utils": "8.44.1", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -5833,9 +5886,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.1.tgz", - "integrity": "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", + "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", "dev": true, "license": "MIT", "engines": { @@ -5847,16 +5900,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz", - "integrity": "sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", + "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.44.1", - "@typescript-eslint/tsconfig-utils": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/visitor-keys": "8.44.1", + "@typescript-eslint/project-service": "8.46.2", + "@typescript-eslint/tsconfig-utils": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5932,16 +5985,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.1.tgz", - "integrity": "sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", + "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.44.1", - "@typescript-eslint/types": "8.44.1", - "@typescript-eslint/typescript-estree": "8.44.1" + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5956,13 +6009,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.44.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz", - "integrity": "sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", + "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.44.1", + "@typescript-eslint/types": "8.46.2", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -6276,12 +6329,12 @@ } }, "node_modules/@xyflow/react": { - "version": "12.8.5", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.5.tgz", - "integrity": "sha512-NRwcE8QE7dh6BbaIT7GmNccP7/RMDZJOKtzK4HQw599TAfzC8e5E/zw/7MwtpnSbbkqBYc+jZyOisd57sp/hPQ==", + "version": "12.9.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz", + "integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.69", + "@xyflow/system": "0.0.72", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -6290,10 +6343,38 @@ "react-dom": ">=17" } }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@xyflow/system": { - "version": "0.0.69", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.69.tgz", - "integrity": "sha512-+KYwHDnsapZQ1xSgsYwOKYN93fUR770LwfCT5qrvcmzoMaabO1rHa6twiEk7E5VUIceWciF8ukgfq9JC83B5jQ==", + "version": "0.0.72", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz", + "integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -6307,6 +6388,28 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/@zxing/library": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", + "integrity": "sha512-hZHqFe2JyH/ZxviJZosZjV+2s6EDSY0O24R+FQmlWZBZXP9IqMo7S3nb3+2LBWxodJQkSurdQGnqE7KXqrYgow==", + "license": "MIT", + "dependencies": { + "ts-custom-error": "^3.2.1" + }, + "engines": { + "node": ">= 10.4.0" + }, + "optionalDependencies": { + "@zxing/text-encoding": "~0.9.0" + } + }, + "node_modules/@zxing/text-encoding": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", + "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", + "license": "(Unlicense OR Apache-2.0)", + "optional": true + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -6617,9 +6720,9 @@ } }, "node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", "dev": true, "license": "MPL-2.0", "engines": { @@ -6627,9 +6730,9 @@ } }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -6857,9 +6960,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001745", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", - "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", + "version": "1.0.30001753", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", + "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", "funding": [ { "type": "opencollective", @@ -6951,16 +7054,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/citty": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", @@ -7075,10 +7168,13 @@ } }, "node_modules/commander": { - "version": "2.17.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", - "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", - "license": "MIT" + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } }, "node_modules/concat-map": { "version": "0.0.1", @@ -7099,12 +7195,6 @@ "tinyqueue": "^2.0.3" } }, - "node_modules/concaveman/node_modules/robust-predicates": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", - "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", - "license": "Unlicense" - }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -7207,9 +7297,9 @@ } }, "node_modules/cssstyle": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", - "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.2.tgz", + "integrity": "sha512-zDMqXh8Vs1CdRYZQ2M633m/SFgcjlu8RB8b/1h82i+6vpArF507NSYIWJHGlJaTWoS+imcnctmEz43txhbVkOw==", "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^4.0.3", @@ -7396,15 +7486,6 @@ "node": ">=12" } }, - "node_modules/d3-dsv/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -7450,17 +7531,20 @@ } }, "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz", + "integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==", + "license": "BSD-3-Clause", "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" + "d3-array": "1" } }, + "node_modules/d3-geo/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, "node_modules/d3-hierarchy": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", @@ -7642,6 +7726,18 @@ "node": ">=12" } }, + "node_modules/d3/node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -7830,6 +7926,12 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/delaunator/node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7856,9 +7958,9 @@ } }, "node_modules/detect-libc": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", - "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "devOptional": true, "license": "Apache-2.0", "engines": { @@ -7888,15 +7990,6 @@ "redux": "^4.2.0" } }, - "node_modules/dnd-core/node_modules/redux": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", - "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -7928,45 +8021,27 @@ } }, "node_modules/docx-preview": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.6.tgz", - "integrity": "sha512-gKVPE18hlpfuhQHiptsw1rbOwzQeGSwK10/w7hv1ZMEqHmjtCuTpz6AUMfu1twIPGxgpcsMXThKI6B6WsP3L1w==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.7.tgz", + "integrity": "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==", "license": "Apache-2.0", "dependencies": { "jszip": ">=3.0.0" } }, "node_modules/docx/node_modules/@types/node": { - "version": "24.6.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.1.tgz", - "integrity": "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==", + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "license": "MIT", "dependencies": { - "undici-types": "~7.13.0" - } - }, - "node_modules/docx/node_modules/nanoid": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" + "undici-types": "~7.16.0" } }, "node_modules/docx/node_modules/undici-types": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", - "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/dompurify": { @@ -8027,9 +8102,9 @@ "license": "ISC" }, "node_modules/effect": { - "version": "3.16.12", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz", - "integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==", + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", "dev": true, "license": "MIT", "dependencies": { @@ -8254,9 +8329,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.39.10", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz", - "integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==", + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.41.0.tgz", + "integrity": "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA==", "license": "MIT", "workspaces": [ "docs", @@ -8277,25 +8352,24 @@ } }, "node_modules/eslint": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", - "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "version": "9.39.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.0.tgz", + "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.36.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/js": "9.39.0", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -8862,12 +8936,6 @@ "pako": "^2.1.0" } }, - "node_modules/fast-png/node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -9049,6 +9117,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/geojson-equality-ts": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/geojson-equality-ts/-/geojson-equality-ts-1.0.2.tgz", @@ -9147,9 +9225,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9227,9 +9305,9 @@ "license": "MIT" }, "node_modules/goober": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", - "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", "license": "MIT", "peerDependencies": { "csstype": "^3.0.10" @@ -9363,9 +9441,9 @@ } }, "node_modules/hls.js": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz", - "integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==", + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.14.tgz", + "integrity": "sha512-CSpT2aXsv71HST8C5ETeVo+6YybqCpHBiYrCRQSn3U5QUZuLTSsvtq/bj+zuvjLVADeKxoebzo16OkH8m1+65Q==", "license": "Apache-2.0" }, "node_modules/hoist-non-react-statics": { @@ -9377,6 +9455,12 @@ "react-is": "^16.7.0" } }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -9483,9 +9567,9 @@ "license": "MIT" }, "node_modules/immer": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz", - "integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", "funding": { "type": "opencollective", @@ -9727,14 +9811,15 @@ } }, "node_modules/is-generator-function": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", - "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-proto": "^1.0.0", + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" }, @@ -9969,10 +10054,9 @@ } }, "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, "node_modules/isexe": { @@ -9982,16 +10066,16 @@ "license": "ISC" }, "node_modules/isomorphic-dompurify": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.28.0.tgz", - "integrity": "sha512-9G5v8g4tYoix5odskjG704Khm1zNrqqqOC4YjCwEUhx0OvuaijRCprAV2GwJ9iw/01c6H1R+rs/2AXPZLlgDaQ==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.31.0.tgz", + "integrity": "sha512-/XPACpfVJeEiy28UgkBWUWdhgKN8xwFYkoVFsqrcSJJ5pXZ3HStuF3ih/Hr8PwhCXHqFAys+b4tcgw0pbUT4rw==", "license": "MIT", "dependencies": { - "dompurify": "^3.2.7", - "jsdom": "^27.0.0" + "dompurify": "^3.3.0", + "jsdom": "^27.1.0" }, "engines": { - "node": ">=18" + "node": ">=20.19.5" } }, "node_modules/iterator.prototype": { @@ -10034,9 +10118,9 @@ } }, "node_modules/jiti": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", - "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", "bin": { @@ -10064,21 +10148,21 @@ } }, "node_modules/jsdom": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", - "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", + "version": "27.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.1.0.tgz", + "integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==", "license": "MIT", "dependencies": { - "@asamuzakjp/dom-selector": "^6.5.4", - "cssstyle": "^5.3.0", + "@acemir/cssom": "^0.9.19", + "@asamuzakjp/dom-selector": "^6.7.3", + "cssstyle": "^5.3.2", "data-urls": "^6.0.0", - "decimal.js": "^10.5.0", + "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^7.3.0", - "rrweb-cssom": "^0.8.0", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", @@ -10086,12 +10170,12 @@ "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0", - "ws": "^8.18.2", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=20" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -10190,6 +10274,12 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10250,9 +10340,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -10266,22 +10356,44 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", "cpu": [ "arm64" ], @@ -10300,9 +10412,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", "cpu": [ "x64" ], @@ -10321,9 +10433,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", "cpu": [ "x64" ], @@ -10342,9 +10454,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", "cpu": [ "arm" ], @@ -10363,9 +10475,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", "cpu": [ "arm64" ], @@ -10384,9 +10496,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", "cpu": [ "arm64" ], @@ -10405,9 +10517,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", "cpu": [ "x64" ], @@ -10426,9 +10538,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", "cpu": [ "x64" ], @@ -10447,9 +10559,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", "cpu": [ "arm64" ], @@ -10468,9 +10580,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", "cpu": [ "x64" ], @@ -10564,9 +10676,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10716,29 +10828,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10746,9 +10835,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", "funding": [ { "type": "github", @@ -10757,16 +10846,16 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/napi-postinstall": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", - "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", "dev": true, "license": "MIT", "bin": { @@ -10838,6 +10927,24 @@ } } }, + "node_modules/next/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -11098,9 +11205,9 @@ } }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, "node_modules/parent-module": { @@ -11117,9 +11224,9 @@ } }, "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -11230,6 +11337,12 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/point-in-polygon-hao/node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/polyclip-ts": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/polyclip-ts/-/polyclip-ts-0.16.8.tgz", @@ -11254,6 +11367,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -11278,6 +11392,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/potpack": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", @@ -11423,15 +11556,15 @@ } }, "node_modules/prisma": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz", - "integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.18.0.tgz", + "integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "6.16.2", - "@prisma/engines": "6.16.2" + "@prisma/config": "6.18.0", + "@prisma/engines": "6.18.0" }, "bin": { "prisma": "build/index.js" @@ -11476,6 +11609,13 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -11646,10 +11786,16 @@ "react": "^19.1.0" } }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, "node_modules/react-hook-form": { - "version": "7.63.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", - "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", + "version": "7.66.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", + "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -11680,10 +11826,11 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "license": "MIT", + "peer": true }, "node_modules/react-leaflet": { "version": "5.0.0", @@ -11714,35 +11861,6 @@ "react": "^19.0.0" } }, - "node_modules/react-reconciler/node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" - }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-remove-scroll": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", @@ -11837,10 +11955,20 @@ } } }, + "node_modules/react-webcam": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/react-webcam/-/react-webcam-7.2.0.tgz", + "integrity": "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.2.0", + "react-dom": ">=16.2.0" + } + }, "node_modules/react-window": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.1.1.tgz", - "integrity": "sha512-Wx5yHri8G1nFxImnJRkEEKtRTnG3cWaqknUJyYvgisQtl1mw/d8LQmLXfuKxpn2dY8IwDn5mCIuxm2NVyIvgVQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.2.tgz", + "integrity": "sha512-kvHKwFImKBWNbx2S87NZOhQhAVkBthjmnOfHlhQI45p3A+D+V53E+CqQMsyHrxNe3ke+YtWXuKDa1eoHAaIWJg==", "license": "MIT", "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -11880,12 +12008,6 @@ "util-deprecate": "~1.0.1" } }, - "node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -11901,9 +12023,9 @@ } }, "node_modules/recharts": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz", - "integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.3.0.tgz", + "integrity": "sha512-Vi0qmTB0iz1+/Cz9o5B7irVyUjX2ynvEgImbgMt/3sKRREcUM07QiYjS1QpAVrkmVlXqy5gykq4nGWMz9AS4Rg==", "license": "MIT", "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", @@ -11927,13 +12049,62 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/redux": { + "node_modules/recharts/node_modules/@reduxjs/toolkit": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz", + "integrity": "sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/recharts/node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts/node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT" }, - "node_modules/redux-thunk": { + "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", @@ -11942,6 +12113,15 @@ "redux": "^5.0.0" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -12009,13 +12189,13 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -12071,17 +12251,11 @@ } }, "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", + "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", "license": "Unlicense" }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12132,6 +12306,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -12155,6 +12336,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-regex-test": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", @@ -12198,15 +12386,15 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "devOptional": true, "license": "ISC", "bin": { @@ -12357,6 +12545,12 @@ "node": ">=0.8" } }, + "node_modules/sheetjs-style/node_modules/commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "license": "MIT" + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -12797,16 +12991,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.13", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", - "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { @@ -12817,23 +13011,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -12993,6 +13170,12 @@ "topoquantize": "bin/topoquantize" } }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/topojson-server": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/topojson-server/-/topojson-server-3.0.1.tgz", @@ -13005,6 +13188,12 @@ "geo2topo": "bin/geo2topo" } }, + "node_modules/topojson-server/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -13072,6 +13261,15 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-custom-error": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz", + "integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -13100,6 +13298,34 @@ "zustand": "^4.3.2" } }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -13202,9 +13428,9 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -13336,9 +13562,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -13532,6 +13758,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/which-collection": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", @@ -13715,16 +13948,6 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -13739,29 +13962,27 @@ } }, "node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "peerDependencies": { - "@types/react": ">=16.8", + "@types/react": ">=18.0.0", "immer": ">=9.0.6", - "react": ">=16.8" + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -13772,6 +13993,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/frontend/package.json b/frontend/package.json index fe7fc518..6d4f3369 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,6 +52,7 @@ "@types/react-window": "^1.8.8", "@types/three": "^0.180.0", "@xyflow/react": "^12.8.4", + "@zxing/library": "^0.21.3", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -77,6 +78,7 @@ "react-hot-toast": "^2.6.0", "react-leaflet": "^5.0.0", "react-resizable-panels": "^3.0.6", + "react-webcam": "^7.2.0", "react-window": "^2.1.0", "reactflow": "^11.11.4", "recharts": "^3.2.1", diff --git a/frontend/types/company.ts b/frontend/types/company.ts index 503ce856..d4c9272a 100644 --- a/frontend/types/company.ts +++ b/frontend/types/company.ts @@ -6,6 +6,12 @@ export interface Company { company_code: string; // 회사 코드 (varchar 32) - PK company_name: string; // 회사명 (varchar 64) + business_registration_number?: string; // 사업자등록번호 (varchar 20) + representative_name?: string; // 대표자명 (varchar 100) + representative_phone?: string; // 대표 연락처 (varchar 20) + email?: string; // 이메일 (varchar 255) + website?: string; // 웹사이트 (varchar 500) + address?: string; // 회사 주소 (text) writer: string; // 등록자 (varchar 32) regdate: string; // 등록일시 (timestamp -> ISO string) status: string; // 상태 (varchar 32) @@ -20,7 +26,13 @@ export interface Company { // 회사 등록/수정 폼 데이터 export interface CompanyFormData { - company_name: string; // 등록 시에는 회사명만 필요 + company_name: string; // 회사명 (필수) + business_registration_number: string; // 사업자등록번호 (필수) + representative_name?: string; // 대표자명 + representative_phone?: string; // 대표 연락처 + email?: string; // 이메일 + website?: string; // 웹사이트 + address?: string; // 회사 주소 } // 회사 검색 필터 diff --git a/frontend/types/department.ts b/frontend/types/department.ts new file mode 100644 index 00000000..0abc25d0 --- /dev/null +++ b/frontend/types/department.ts @@ -0,0 +1,69 @@ +/** + * 부서 관리 관련 타입 정의 + */ + +// 부서 정보 (dept_info 테이블 기반) +export interface Department { + dept_code: string; // 부서 코드 + dept_name: string; // 부서명 + company_code: string; // 회사 코드 + parent_dept_code?: string | null; // 상위 부서 코드 + sort_order?: number; // 정렬 순서 + created_at?: string; + updated_at?: string; + // UI용 추가 필드 + children?: Department[]; // 하위 부서 목록 + memberCount?: number; // 부서원 수 +} + +// 부서원 정보 +export interface DepartmentMember { + user_id: string; // 사용자 ID + user_name: string; // 사용자명 + dept_code: string; // 부서 코드 + dept_name: string; // 부서명 + is_primary: boolean; // 주 부서 여부 + position_name?: string; // 직책명 + email?: string; // 이메일 + phone?: string; // 전화번호 + cell_phone?: string; // 휴대폰 +} + +// 사용자-부서 매핑 (겸직 지원) +export interface UserDepartmentMapping { + user_id: string; + dept_code: string; + is_primary: boolean; // 주 부서 여부 + created_at?: string; +} + +// 부서 등록/수정 폼 데이터 +export interface DepartmentFormData { + dept_name: string; // 부서명 (필수) + parent_dept_code?: string | null; // 상위 부서 코드 +} + +// 부서 트리 노드 (UI용) +export interface DepartmentTreeNode { + dept_code: string; + dept_name: string; + parent_dept_code?: string | null; + children: DepartmentTreeNode[]; + memberCount: number; + isExpanded: boolean; +} + +// 부서 API 응답 +export interface DepartmentApiResponse { + success: boolean; + message: string; + data?: Department | Department[]; +} + +// 부서원 API 응답 +export interface DepartmentMemberApiResponse { + success: boolean; + message: string; + data?: DepartmentMember | DepartmentMember[]; +} + diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts new file mode 100644 index 00000000..9cd81bdb --- /dev/null +++ b/frontend/types/numbering-rule.ts @@ -0,0 +1,125 @@ +/** + * 코드 채번 규칙 컴포넌트 타입 정의 + * Shadcn/ui 가이드라인 기반 + */ + +/** + * 코드 파트 유형 (4가지) + */ +export type CodePartType = + | "sequence" // 순번 (자동 증가 숫자) + | "number" // 숫자 (고정 자릿수) + | "date" // 날짜 (다양한 날짜 형식) + | "text"; // 문자 (텍스트) + +/** + * 생성 방식 + */ +export type GenerationMethod = + | "auto" // 자동 생성 + | "manual"; // 직접 입력 + +/** + * 날짜 형식 + */ +export type DateFormat = + | "YYYY" // 2025 + | "YY" // 25 + | "YYYYMM" // 202511 + | "YYMM" // 2511 + | "YYYYMMDD" // 20251104 + | "YYMMDD"; // 251104 + +/** + * 단일 규칙 파트 + */ +export interface NumberingRulePart { + id: string; // 고유 ID + order: number; // 순서 (1-6) + partType: CodePartType; // 파트 유형 + generationMethod: GenerationMethod; // 생성 방식 + + // 자동 생성 설정 + autoConfig?: { + // 순번용 + sequenceLength?: number; // 순번 자릿수 (예: 3 → 001) + startFrom?: number; // 시작 번호 (기본: 1) + + // 숫자용 + numberLength?: number; // 숫자 자릿수 (예: 4 → 0001) + numberValue?: number; // 숫자 값 + + // 날짜용 + dateFormat?: DateFormat; // 날짜 형식 + + // 문자용 + textValue?: string; // 텍스트 값 (예: "PRJ", "CODE") + }; + + // 직접 입력 설정 + manualConfig?: { + value: string; // 입력값 + placeholder?: string; // 플레이스홀더 + }; + + // 생성된 값 (미리보기용) + generatedValue?: string; +} + +/** + * 전체 채번 규칙 + */ +export interface NumberingRuleConfig { + ruleId: string; // 규칙 ID + ruleName: string; // 규칙명 + description?: string; // 설명 + parts: NumberingRulePart[]; // 규칙 파트 배열 + + // 설정 + separator?: string; // 구분자 (기본: "-") + resetPeriod?: "none" | "daily" | "monthly" | "yearly"; + currentSequence?: number; // 현재 시퀀스 + + // 적용 범위 + scopeType?: "global" | "menu"; // 적용 범위 (전역/메뉴별) + menuObjid?: number; // 적용할 메뉴 OBJID (상위 메뉴 기준) + + // 적용 대상 + tableName?: string; // 적용할 테이블명 + columnName?: string; // 적용할 컬럼명 + + // 메타 정보 + companyCode?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; +} + +/** + * UI 옵션 상수 + */ +export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string; description: string }> = [ + { value: "sequence", label: "순번", description: "자동 증가 순번 (1, 2, 3...)" }, + { value: "number", label: "숫자", description: "고정 자릿수 숫자 (001, 002...)" }, + { value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" }, + { value: "text", label: "문자", description: "텍스트 또는 코드" }, +]; + +export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [ + { value: "YYYY", label: "연도 (4자리)", example: "2025" }, + { value: "YY", label: "연도 (2자리)", example: "25" }, + { value: "YYYYMM", label: "연도+월", example: "202511" }, + { value: "YYMM", label: "연도(2)+월", example: "2511" }, + { value: "YYYYMMDD", label: "연월일", example: "20251104" }, + { value: "YYMMDD", label: "연(2)+월일", example: "251104" }, +]; + +export const RESET_PERIOD_OPTIONS: Array<{ + value: "none" | "daily" | "monthly" | "yearly"; + label: string; +}> = [ + { value: "none", label: "초기화 안함" }, + { value: "daily", label: "일별 초기화" }, + { value: "monthly", label: "월별 초기화" }, + { value: "yearly", label: "연별 초기화" }, +]; diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index b2eff0f1..56a9ba2d 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -84,6 +84,15 @@ export interface WidgetComponent extends BaseComponent { entityConfig?: EntityTypeConfig; buttonConfig?: ButtonTypeConfig; arrayConfig?: ArrayTypeConfig; + + // 🆕 자동 입력 설정 (테이블 조회 기반) + autoFill?: { + enabled: boolean; // 자동 입력 활성화 + sourceTable: string; // 조회할 테이블 (예: company_mng) + filterColumn: string; // 필터링할 컬럼 (예: company_code) + userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보 필드 + displayColumn: string; // 표시할 컬럼 (예: company_name) + }; } /** @@ -121,6 +130,13 @@ export interface DataTableComponent extends BaseComponent { searchable?: boolean; sortable?: boolean; filters?: DataTableFilter[]; + + // 🆕 현재 사용자 정보로 자동 필터링 + autoFilter?: { + enabled: boolean; // 자동 필터 활성화 여부 + filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code) + userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드 + }; } /** diff --git a/코드_채번_규칙_컴포넌트_구현_계획서.md b/코드_채번_규칙_컴포넌트_구현_계획서.md new file mode 100644 index 00000000..69af1e04 --- /dev/null +++ b/코드_채번_규칙_컴포넌트_구현_계획서.md @@ -0,0 +1,494 @@ +# 코드 채번 규칙 컴포넌트 구현 계획서 + +## 문서 정보 +- **작성일**: 2025-11-03 +- **목적**: Shadcn/ui 가이드라인 기반 코드 채번 규칙 컴포넌트 구현 +- **우선순위**: 중간 +- **디자인 원칙**: 심플하고 깔끔한 UI, 중첩 박스 금지, 일관된 컬러 시스템 + +--- + +## 1. 기능 요구사항 + +### 1.1 핵심 기능 +- 코드 채번 규칙 생성/수정/삭제 +- 동적 규칙 파트 추가/삭제 (최대 6개) +- 실시간 코드 미리보기 +- 규칙 순서 조정 +- 데이터베이스 저장 및 불러오기 + +### 1.2 UI 요구사항 +- 좌측: 코드 목록 (선택적) +- 우측: 규칙 설정 영역 +- 상단: 코드 미리보기 + 규칙명 +- 중앙: 규칙 카드 리스트 +- 하단: 규칙 추가 + 저장 버튼 + +--- + +## 2. 디자인 시스템 (Shadcn/ui 기반) + +### 2.1 색상 사용 규칙 + +```tsx +// 배경 +bg-background // 페이지 배경 +bg-card // 카드 배경 +bg-muted // 약한 배경 (미리보기 등) + +// 텍스트 +text-foreground // 기본 텍스트 +text-muted-foreground // 보조 텍스트 +text-primary // 강조 텍스트 + +// 테두리 +border-border // 기본 테두리 +border-input // 입력 필드 테두리 + +// 버튼 +bg-primary // 주요 버튼 (저장, 추가) +bg-destructive // 삭제 버튼 +variant="outline" // 보조 버튼 (취소) +variant="ghost" // 아이콘 버튼 +``` + +### 2.2 간격 시스템 + +```tsx +// 카드 간 간격 +gap-6 // 24px (카드 사이) + +// 카드 내부 패딩 +p-6 // 24px (CardContent) + +// 폼 필드 간격 +space-y-4 // 16px (입력 필드들) +space-y-3 // 12px (모바일) + +// 섹션 간격 +space-y-6 // 24px (큰 섹션) +``` + +### 2.3 타이포그래피 + +```tsx +// 페이지 제목 +text-2xl font-semibold + +// 섹션 제목 +text-lg font-semibold + +// 카드 제목 +text-base font-semibold + +// 라벨 +text-sm font-medium + +// 본문 텍스트 +text-sm text-muted-foreground + +// 작은 텍스트 +text-xs text-muted-foreground +``` + +### 2.4 반응형 설정 + +```tsx +// 모바일 우선 + 데스크톱 최적화 +className="text-xs sm:text-sm" // 폰트 크기 +className="h-8 sm:h-10" // 입력 필드 높이 +className="flex-col md:flex-row" // 레이아웃 +className="gap-2 sm:gap-4" // 간격 +``` + +### 2.5 중첩 박스 금지 원칙 + +**❌ 잘못된 예시**: +```tsx + + +
{/* 중첩 박스! */} +
{/* 또 중첩! */} + 내용 +
+
+
+
+``` + +**✅ 올바른 예시**: +```tsx + + + 제목 + + + {/* 직접 컨텐츠 배치 */} +
내용 1
+
내용 2
+
+
+``` + +--- + +## 3. 데이터 구조 + +### 3.1 타입 정의 + +```typescript +// frontend/types/numbering-rule.ts + +import { BaseComponent } from "./screen-management"; + +/** + * 코드 파트 유형 + */ +export type CodePartType = + | "prefix" // 접두사 (고정 문자열) + | "sequence" // 순번 (자동 증가) + | "date" // 날짜 (YYYYMMDD 등) + | "year" // 연도 (YYYY) + | "month" // 월 (MM) + | "custom"; // 사용자 정의 + +/** + * 생성 방식 + */ +export type GenerationMethod = + | "auto" // 자동 생성 + | "manual"; // 직접 입력 + +/** + * 날짜 형식 + */ +export type DateFormat = + | "YYYY" // 2025 + | "YY" // 25 + | "YYYYMM" // 202511 + | "YYMM" // 2511 + | "YYYYMMDD" // 20251103 + | "YYMMDD"; // 251103 + +/** + * 단일 규칙 파트 + */ +export interface NumberingRulePart { + id: string; // 고유 ID + order: number; // 순서 (1-6) + partType: CodePartType; // 파트 유형 + generationMethod: GenerationMethod; // 생성 방식 + + // 자동 생성 설정 + autoConfig?: { + // 접두사 설정 + prefix?: string; // 예: "ITM" + + // 순번 설정 + sequenceLength?: number; // 자릿수 (예: 4 → 0001) + startFrom?: number; // 시작 번호 (기본: 1) + + // 날짜 설정 + dateFormat?: DateFormat; // 날짜 형식 + }; + + // 직접 입력 설정 + manualConfig?: { + value: string; // 입력값 + placeholder?: string; // 플레이스홀더 + }; + + // 생성된 값 (미리보기용) + generatedValue?: string; +} + +/** + * 전체 채번 규칙 + */ +export interface NumberingRuleConfig { + ruleId: string; // 규칙 ID + ruleName: string; // 규칙명 + description?: string; // 설명 + parts: NumberingRulePart[]; // 규칙 파트 배열 (최대 6개) + + // 설정 + separator?: string; // 구분자 (기본: "-") + resetPeriod?: "none" | "daily" | "monthly" | "yearly"; // 초기화 주기 + currentSequence?: number; // 현재 시퀀스 + + // 적용 대상 + tableName?: string; // 적용할 테이블명 + columnName?: string; // 적용할 컬럼명 + + // 메타 정보 + companyCode?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; +} + +/** + * 화면관리 컴포넌트 인터페이스 + */ +export interface NumberingRuleComponent extends BaseComponent { + type: "numbering-rule"; + + // 채번 규칙 설정 + ruleConfig: NumberingRuleConfig; + + // UI 설정 + showRuleList?: boolean; // 좌측 목록 표시 여부 + maxRules?: number; // 최대 규칙 개수 (기본: 6) + enableReorder?: boolean; // 순서 변경 허용 여부 + + // 스타일 + cardLayout?: "vertical" | "horizontal"; // 카드 레이아웃 +} +``` + +### 3.2 데이터베이스 스키마 + +```sql +-- db/migrations/034_create_numbering_rules.sql + +-- 채번 규칙 마스터 테이블 +CREATE TABLE IF NOT EXISTS numbering_rules ( + rule_id VARCHAR(50) PRIMARY KEY, + rule_name VARCHAR(100) NOT NULL, + description TEXT, + separator VARCHAR(10) DEFAULT '-', + reset_period VARCHAR(20) DEFAULT 'none', + current_sequence INTEGER DEFAULT 1, + table_name VARCHAR(100), + column_name VARCHAR(100), + company_code VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + + CONSTRAINT fk_company FOREIGN KEY (company_code) + REFERENCES company_info(company_code) +); + +-- 채번 규칙 상세 테이블 +CREATE TABLE IF NOT EXISTS numbering_rule_parts ( + id SERIAL PRIMARY KEY, + rule_id VARCHAR(50) NOT NULL, + part_order INTEGER NOT NULL, + part_type VARCHAR(50) NOT NULL, + generation_method VARCHAR(20) NOT NULL, + auto_config JSONB, + manual_config JSONB, + company_code VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_numbering_rule FOREIGN KEY (rule_id) + REFERENCES numbering_rules(rule_id) ON DELETE CASCADE, + CONSTRAINT fk_company FOREIGN KEY (company_code) + REFERENCES company_info(company_code), + CONSTRAINT unique_rule_order UNIQUE (rule_id, part_order, company_code) +); + +-- 인덱스 +CREATE INDEX idx_numbering_rules_company ON numbering_rules(company_code); +CREATE INDEX idx_numbering_rule_parts_rule ON numbering_rule_parts(rule_id); +CREATE INDEX idx_numbering_rules_table ON numbering_rules(table_name, column_name); + +-- 샘플 데이터 +INSERT INTO numbering_rules (rule_id, rule_name, description, company_code, created_by) +VALUES ('SAMPLE_RULE', '샘플 채번 규칙', '제품 코드 자동 생성', '*', 'system') +ON CONFLICT (rule_id) DO NOTHING; + +INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, company_code) +VALUES + ('SAMPLE_RULE', 1, 'prefix', 'auto', '{"prefix": "PROD"}', '*'), + ('SAMPLE_RULE', 2, 'date', 'auto', '{"dateFormat": "YYYYMMDD"}', '*'), + ('SAMPLE_RULE', 3, 'sequence', 'auto', '{"sequenceLength": 4, "startFrom": 1}', '*') +ON CONFLICT (rule_id, part_order, company_code) DO NOTHING; +``` + +--- + +## 4. 구현 순서 + +### Phase 1: 타입 정의 및 스키마 생성 ✅ +1. 타입 정의 파일 생성 +2. 데이터베이스 마이그레이션 실행 +3. 샘플 데이터 삽입 + +### Phase 2: 백엔드 API 구현 +1. Controller 생성 +2. Service 레이어 구현 +3. API 테스트 + +### Phase 3: 프론트엔드 기본 컴포넌트 +1. NumberingRuleDesigner (메인) +2. NumberingRulePreview (미리보기) +3. NumberingRuleCard (단일 규칙 카드) + +### Phase 4: 상세 설정 패널 +1. PartTypeSelector (파트 유형 선택) +2. AutoConfigPanel (자동 생성 설정) +3. ManualConfigPanel (직접 입력 설정) + +### Phase 5: 화면관리 통합 +1. ComponentType에 "numbering-rule" 추가 +2. RealtimePreview 렌더링 추가 +3. 템플릿 등록 +4. 속성 패널 구현 + +### Phase 6: 테스트 및 최적화 +1. 기능 테스트 +2. 반응형 테스트 +3. 성능 최적화 +4. 문서화 + +--- + +## 5. 구현 완료 ✅ + +### Phase 1: 타입 정의 및 스키마 생성 ✅ +- ✅ `frontend/types/numbering-rule.ts` 생성 +- ✅ `db/migrations/034_create_numbering_rules.sql` 생성 및 실행 +- ✅ 샘플 데이터 삽입 완료 + +### Phase 2: 백엔드 API 구현 ✅ +- ✅ `backend-node/src/services/numberingRuleService.ts` 생성 +- ✅ `backend-node/src/controllers/numberingRuleController.ts` 생성 +- ✅ `app.ts`에 라우터 등록 (`/api/numbering-rules`) +- ✅ 백엔드 재시작 완료 + +### Phase 3: 프론트엔드 기본 컴포넌트 ✅ +- ✅ `NumberingRulePreview.tsx` - 코드 미리보기 +- ✅ `NumberingRuleCard.tsx` - 단일 규칙 카드 +- ✅ `AutoConfigPanel.tsx` - 자동 생성 설정 +- ✅ `ManualConfigPanel.tsx` - 직접 입력 설정 +- ✅ `NumberingRuleDesigner.tsx` - 메인 디자이너 + +### Phase 4: 상세 설정 패널 ✅ +- ✅ 파트 유형별 설정 UI (접두사, 순번, 날짜, 연도, 월, 커스텀) +- ✅ 자동 생성 / 직접 입력 모드 전환 +- ✅ 실시간 미리보기 업데이트 + +### Phase 5: 화면관리 시스템 통합 ✅ +- ✅ `unified-core.ts`에 "numbering-rule" ComponentType 추가 +- ✅ `screen-management.ts`에 ComponentData 유니온 타입 추가 +- ✅ `RealtimePreview.tsx`에 렌더링 로직 추가 +- ✅ `TemplatesPanel.tsx`에 "관리자" 카테고리 및 템플릿 추가 +- ✅ `NumberingRuleTemplate.ts` 생성 + +### Phase 6: 완료 ✅ +모든 단계가 성공적으로 완료되었습니다! + +--- + +## 6. 사용 방법 + +### 6.1 화면관리에서 사용하기 + +1. **화면관리** 페이지로 이동 +2. 좌측 **템플릿 패널**에서 **관리자** 카테고리 선택 +3. **코드 채번 규칙** 템플릿을 캔버스로 드래그 +4. 규칙 파트 추가 및 설정 +5. 저장 + +### 6.2 API 사용하기 + +#### 규칙 목록 조회 +```bash +GET /api/numbering-rules +``` + +#### 규칙 생성 +```bash +POST /api/numbering-rules +{ + "ruleId": "PROD_CODE", + "ruleName": "제품 코드 규칙", + "parts": [ + { + "id": "part-1", + "order": 1, + "partType": "prefix", + "generationMethod": "auto", + "autoConfig": { "prefix": "PROD" } + }, + { + "id": "part-2", + "order": 2, + "partType": "date", + "generationMethod": "auto", + "autoConfig": { "dateFormat": "YYYYMMDD" } + }, + { + "id": "part-3", + "order": 3, + "partType": "sequence", + "generationMethod": "auto", + "autoConfig": { "sequenceLength": 4, "startFrom": 1 } + } + ], + "separator": "-" +} +``` + +#### 코드 생성 +```bash +POST /api/numbering-rules/PROD_CODE/generate + +응답: { "success": true, "data": { "code": "PROD-20251103-0001" } } +``` + +--- + +## 7. 구현된 파일 목록 + +### 프론트엔드 +``` +frontend/ +├── types/ +│ └── numbering-rule.ts ✅ +├── components/ +│ └── numbering-rule/ +│ ├── NumberingRuleDesigner.tsx ✅ +│ ├── NumberingRuleCard.tsx ✅ +│ ├── NumberingRulePreview.tsx ✅ +│ ├── AutoConfigPanel.tsx ✅ +│ └── ManualConfigPanel.tsx ✅ +└── components/screen/ + ├── RealtimePreview.tsx ✅ (수정됨) + ├── panels/ + │ └── TemplatesPanel.tsx ✅ (수정됨) + └── templates/ + └── NumberingRuleTemplate.ts ✅ +``` + +### 백엔드 +``` +backend-node/ +├── src/ +│ ├── services/ +│ │ └── numberingRuleService.ts ✅ +│ ├── controllers/ +│ │ └── numberingRuleController.ts ✅ +│ └── app.ts ✅ (수정됨) +``` + +### 데이터베이스 +``` +db/ +└── migrations/ + └── 034_create_numbering_rules.sql ✅ +``` + +--- + +## 8. 다음 개선 사항 (선택사항) + +- [ ] 규칙 순서 드래그앤드롭으로 변경 +- [ ] 규칙 복제 기능 +- [ ] 규칙 템플릿 제공 (자주 사용하는 패턴) +- [ ] 코드 검증 로직 +- [ ] 테이블 생성 시 자동 채번 컬럼 추가 통합 +- [ ] 화면관리에서 입력 폼에 자동 코드 생성 버튼 추가 + diff --git a/테이블_그룹핑_기능_구현_계획서.md b/테이블_그룹핑_기능_구현_계획서.md new file mode 100644 index 00000000..b6b86afa --- /dev/null +++ b/테이블_그룹핑_기능_구현_계획서.md @@ -0,0 +1,365 @@ +# 테이블 그룹핑 기능 구현 계획서 + +## 📋 개요 + +테이블 리스트 컴포넌트와 플로우 위젯에 그룹핑 기능을 추가하여, 사용자가 선택한 컬럼(들)을 기준으로 데이터를 그룹화하여 표시합니다. + +## 🎯 핵심 요구사항 + +### 1. 기능 요구사항 +- ✅ 그룹핑할 컬럼을 다중 선택 가능 +- ✅ 선택한 컬럼 순서대로 계층적 그룹화 +- ✅ 그룹 헤더에 그룹 정보와 데이터 개수 표시 +- ✅ 그룹 펼치기/접기 기능 +- ✅ localStorage에 그룹 설정 저장/복원 +- ✅ 그룹 해제 기능 + +### 2. 적용 대상 +- TableListComponent (`frontend/lib/registry/components/table-list/TableListComponent.tsx`) +- FlowWidget (`frontend/components/screen/widgets/FlowWidget.tsx`) + +## 🎨 UI 디자인 + +### 그룹 설정 다이얼로그 + +```tsx +┌─────────────────────────────────────┐ +│ 📊 그룹 설정 │ +│ 데이터를 그룹화할 컬럼을 선택하세요 │ +├─────────────────────────────────────┤ +│ │ +│ [x] 통화 │ +│ [ ] 단위 │ +│ [ ] 품목코드 │ +│ [ ] 품목명 │ +│ [ ] 규격 │ +│ │ +│ 💡 선택된 그룹: 통화 │ +│ │ +├─────────────────────────────────────┤ +│ [취소] [적용] │ +└─────────────────────────────────────┘ +``` + +### 그룹화된 테이블 표시 + +```tsx +┌─────────────────────────────────────────────────────┐ +│ 📦 판매품목 목록 총 3개 [🎨 그룹: 통화 ×] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ ▼ 통화: KRW > 단위: EA (2건) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │ +│ ├─────────────────────────────────────────────┤ │ +│ │ SALE-001 │ 볼트 M8x20 │ M8x20 │ EA │ │ +│ │ SALE-004 │ 스프링 와셔 │ M10 │ EA │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ▼ 통화: USD > 단위: EA (1건) │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ 품목코드 │ 품목명 │ 규격 │ 단위 │ │ +│ ├─────────────────────────────────────────────┤ │ +│ │ SALE-002 │ 너트 M8 │ M8 │ EA │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +## 🔧 기술 구현 + +### 1. 상태 관리 + +```typescript +// 그룹 설정 관련 상태 +const [groupByColumns, setGroupByColumns] = useState([]); // 그룹화할 컬럼 목록 +const [isGroupSettingOpen, setIsGroupSettingOpen] = useState(false); // 그룹 설정 다이얼로그 +const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); // 접힌 그룹 +``` + +### 2. 데이터 그룹화 로직 + +```typescript +interface GroupedData { + groupKey: string; // "통화:KRW > 단위:EA" + groupValues: Record; // { 통화: "KRW", 단위: "EA" } + items: any[]; // 그룹에 속한 데이터 + count: number; // 항목 개수 +} + +const groupDataByColumns = ( + data: any[], + groupColumns: string[] +): GroupedData[] => { + if (groupColumns.length === 0) return []; + + const grouped = new Map(); + + data.forEach(item => { + // 그룹 키 생성: "통화:KRW > 단위:EA" + const keyParts = groupColumns.map(col => `${col}:${item[col] || '-'}`); + const groupKey = keyParts.join(' > '); + + if (!grouped.has(groupKey)) { + grouped.set(groupKey, []); + } + grouped.get(groupKey)!.push(item); + }); + + return Array.from(grouped.entries()).map(([groupKey, items]) => { + const groupValues: Record = {}; + groupColumns.forEach(col => { + groupValues[col] = items[0]?.[col]; + }); + + return { + groupKey, + groupValues, + items, + count: items.length, + }; + }); +}; +``` + +### 3. localStorage 저장/로드 + +```typescript +// 저장 키 +const groupSettingKey = useMemo(() => { + if (!tableConfig.selectedTable) return null; + return `table-list-group-${tableConfig.selectedTable}`; +}, [tableConfig.selectedTable]); + +// 그룹 설정 저장 +const saveGroupSettings = useCallback(() => { + if (!groupSettingKey) return; + + try { + localStorage.setItem(groupSettingKey, JSON.stringify(groupByColumns)); + setIsGroupSettingOpen(false); + toast.success("그룹 설정이 저장되었습니다"); + } catch (error) { + console.error("그룹 설정 저장 실패:", error); + toast.error("설정 저장에 실패했습니다"); + } +}, [groupSettingKey, groupByColumns]); + +// 그룹 설정 로드 +useEffect(() => { + if (!groupSettingKey || visibleColumns.length === 0) return; + + try { + const saved = localStorage.getItem(groupSettingKey); + if (saved) { + const savedGroups = JSON.parse(saved); + setGroupByColumns(savedGroups); + } + } catch (error) { + console.error("그룹 설정 불러오기 실패:", error); + } +}, [groupSettingKey, visibleColumns]); +``` + +### 4. 그룹 헤더 렌더링 + +```tsx +const renderGroupHeader = (group: GroupedData) => { + const isCollapsed = collapsedGroups.has(group.groupKey); + + return ( +
toggleGroupCollapse(group.groupKey)} + > + {/* 펼치기/접기 아이콘 */} + {isCollapsed ? ( + + ) : ( + + )} + + {/* 그룹 정보 */} + + {groupByColumns.map((col, idx) => ( + + {idx > 0 && > } + {columnLabels[col] || col}: + {" "} + {group.groupValues[col]} + + ))} + + + {/* 항목 개수 */} + + ({group.count}건) + +
+ ); +}; +``` + +### 5. 그룹 설정 다이얼로그 + +```tsx + + + + 그룹 설정 + + 데이터를 그룹화할 컬럼을 선택하세요. 여러 컬럼을 선택하면 계층적으로 그룹화됩니다. + + + +
+ {/* 컬럼 목록 */} +
+ {visibleColumns + .filter((col) => col.columnName !== "__checkbox__") + .map((col) => ( +
+ toggleGroupColumn(col.columnName)} + /> + +
+ ))} +
+ + {/* 선택된 그룹 안내 */} +
+ {groupByColumns.length === 0 ? ( + 그룹화할 컬럼을 선택하세요 + ) : ( + + 선택된 그룹: + {groupByColumns.map((col) => columnLabels[col] || col).join(" → ")} + + + )} +
+
+ + + + + +
+
+``` + +### 6. 그룹 해제 버튼 + +```tsx +{/* 헤더 영역 */} +
+

{tableLabel}

+
+ {/* 그룹 표시 배지 */} + {groupByColumns.length > 0 && ( +
+ 그룹: {groupByColumns.map(col => columnLabels[col] || col).join(", ")} + +
+ )} + + {/* 그룹 설정 버튼 */} + +
+
+``` + +## 📝 구현 순서 + +### Phase 1: TableListComponent 구현 +1. ✅ 상태 관리 추가 (groupByColumns, isGroupSettingOpen, collapsedGroups) +2. ✅ 그룹화 로직 구현 (groupDataByColumns 함수) +3. ✅ localStorage 저장/로드 로직 +4. ✅ 그룹 설정 다이얼로그 UI +5. ✅ 그룹 헤더 렌더링 +6. ✅ 그룹별 데이터 렌더링 +7. ✅ 그룹 해제 기능 + +### Phase 2: FlowWidget 구현 +1. ✅ TableListComponent와 동일한 로직 적용 +2. ✅ 스텝 데이터에 그룹화 적용 +3. ✅ UI 통일성 유지 + +### Phase 3: 테스트 및 최적화 +1. ✅ 다중 그룹 계층 테스트 +2. ✅ 대량 데이터 성능 테스트 +3. ✅ localStorage 저장/복원 테스트 +4. ✅ 그룹 펼치기/접기 테스트 + +## 🎯 예상 효과 + +### 사용자 경험 개선 +- 데이터를 논리적으로 그룹화하여 가독성 향상 +- 대량 데이터를 효율적으로 탐색 가능 +- 사용자 정의 뷰 제공 + +### 데이터 분석 지원 +- 카테고리별 데이터 분석 용이 +- 통계 정보 제공 (그룹별 개수) +- 계층적 데이터 구조 시각화 + +## ⚠️ 주의사항 + +### 성능 고려사항 +- 그룹화는 클라이언트 측에서 수행 +- 대량 데이터의 경우 성능 저하 가능 +- 필요시 서버 측 그룹화로 전환 검토 + +### 사용성 +- 그룹화 해제가 쉽게 가능해야 함 +- 그룹 설정이 직관적이어야 함 +- 모바일에서도 사용 가능한 UI + +## 📊 구현 상태 + +- [ ] Phase 1: TableListComponent 구현 + - [ ] 상태 관리 추가 + - [ ] 그룹화 로직 구현 + - [ ] localStorage 연동 + - [ ] UI 구현 +- [ ] Phase 2: FlowWidget 구현 +- [ ] Phase 3: 테스트 및 최적화 + +--- + +**작성일**: 2025-11-03 +**버전**: 1.0 +**상태**: 구현 예정 + diff --git a/화면관리_및_테이블관리_개선사항_목록.md b/화면관리_및_테이블관리_개선사항_목록.md new file mode 100644 index 00000000..666f41f1 --- /dev/null +++ b/화면관리_및_테이블관리_개선사항_목록.md @@ -0,0 +1,386 @@ +# 화면관리 및 테이블관리 시스템 개선사항 목록 + +## 문서 정보 +- **작성일**: 2025-11-03 +- **목적**: 사용자 피드백 기반 개선사항 정리 +- **우선순위**: 높음 + +--- + +## 1. 화면관리 (Screen Management) 개선사항 + +### 1.1 리스트 컬럼 Width 조절 기능 +**현재 문제**: 리스트 컬럼의 너비가 고정되어 있어 사용자가 조절할 수 없음 + +**요구사항**: +- 사용자가 각 컬럼의 너비를 드래그로 조절할 수 있어야 함 +- 조절된 너비는 저장되어 다음 접속 시에도 유지되어야 함 +- 최소/최대 너비 제한 필요 + +**구현 방안**: +- 컬럼 헤더에 리사이저 핸들 추가 +- `ComponentData` 인터페이스에 `columnWidths` 속성 추가 +- PropertiesPanel에서 개별 컬럼 너비 설정 UI 제공 + +**관련 파일**: +- `frontend/components/screen/ScreenDesigner.tsx` +- `frontend/components/screen/RealtimePreview.tsx` +- `frontend/types/screen.ts` + +--- + +### 1.2 되돌리기(Undo) 단축키 에러 수정 +**현재 문제**: 되돌리기 단축키(Ctrl+Z/Cmd+Z) 실행 시 에러 발생 + +**요구사항**: +- 되돌리기 기능이 안정적으로 작동해야 함 +- 다시 실행(Redo) 기능도 함께 제공 (Ctrl+Y/Cmd+Shift+Z) + +**구현 방안**: +- 히스토리 스택 구현 (최대 50개 상태 저장) +- `useUndo` 커스텀 훅 생성 +- 키보드 단축키 이벤트 리스너 추가 + +**관련 파일**: +- `frontend/hooks/useUndo.ts` (신규 생성) +- `frontend/components/screen/ScreenDesigner.tsx` + +--- + +### 1.3 리스트 헤더 스타일 개선 +**현재 문제**: 리스트 헤더가 눈에 잘 띄지 않음 + +**요구사항**: +- 헤더가 시각적으로 구분되어야 함 +- 배경색, 폰트 굵기, 테두리 등으로 강조 + +**구현 방안**: +- 헤더 기본 스타일 변경: + - 배경색: `bg-muted` → `bg-primary/10` + - 폰트: `font-medium` → `font-semibold` + - 하단 테두리: `border-b-2 border-primary` + +**관련 파일**: +- `frontend/components/screen/RealtimePreview.tsx` +- `frontend/components/screen-viewer/InteractiveScreenViewer.tsx` + +--- + +### 1.4 텍스트 줄바꿈 문제 방지 +**현재 문제**: 화면을 줄였을 때 텍스트가 2줄로 나뉘거나 깨지는 현상 + +**요구사항**: +- 텍스트가 항상 1줄로 표시되어야 함 +- 긴 텍스트는 말줄임표(...) 처리 + +**구현 방안**: +- 모든 텍스트 요소에 다음 클래스 적용: + ```tsx + className="truncate whitespace-nowrap overflow-hidden" + ``` +- 툴팁으로 전체 텍스트 표시 + +**관련 파일**: +- 모든 컴포넌트의 텍스트 렌더링 부분 + +--- + +### 1.5 수정 모달 자동 닫기 +**현재 문제**: 수정 완료 후 모달이 자동으로 닫히지 않음 + +**요구사항**: +- 수정 완료 시 모달이 즉시 닫혀야 함 +- 성공 메시지 표시 후 닫기 + +**구현 방안**: +```typescript +const handleUpdate = async () => { + const result = await updateData(formData); + if (result.success) { + toast.success("수정이 완료되었습니다"); + setIsModalOpen(false); // 모달 닫기 + refreshList(); // 목록 새로고침 + } +}; +``` + +**관련 파일**: +- `frontend/components/screen-viewer/InteractiveScreenViewer.tsx` + +--- + +### 1.6 테이블 Align 조절 기능 +**현재 문제**: 테이블 컬럼의 정렬(align)을 사용자가 조절할 수 없음 + +**요구사항**: +- 각 컬럼의 정렬을 left/center/right로 설정 가능해야 함 +- 숫자 타입은 기본적으로 right 정렬 + +**구현 방안**: +- `TableColumnConfig` 인터페이스에 `align` 속성 추가 +- PropertiesPanel에서 정렬 선택 UI 제공 +- 컬럼 타입별 기본 정렬 설정 + +**관련 파일**: +- `frontend/types/screen.ts` +- `frontend/components/screen/PropertiesPanel.tsx` + +--- + +### 1.7 숫자 천 단위 콤마 표시 +**현재 문제**: 숫자가 콤마 없이 표시됨 + +**요구사항**: +- 모든 숫자는 천 단위마다 콤마(,)를 찍어야 함 +- 예: 1000000 → 1,000,000 + +**구현 방안**: +```typescript +// 유틸리티 함수 생성 +export const formatNumber = (value: number | string): string => { + const num = typeof value === "string" ? parseFloat(value) : value; + if (isNaN(num)) return "0"; + return new Intl.NumberFormat("ko-KR").format(num); +}; +``` + +**관련 파일**: +- `frontend/lib/utils/numberFormat.ts` (신규 생성) +- 모든 숫자 표시 컴포넌트 + +--- + +### 1.8 Drilldown UI 개선 +**현재 문제**: 화면이 횡으로 너무 길게 나열됨 + +**요구사항**: +- 계층적 구조로 정보 표시 +- 펼치기/접기 기능으로 공간 절약 + +**구현 방안**: +- Accordion 컴포넌트 활용 +- 탭 네비게이션 구조 적용 +- 마스터-디테일 레이아웃 패턴 + +**관련 파일**: +- `frontend/components/screen/ScreenDesigner.tsx` +- `frontend/components/ui/accordion.tsx` + +--- + +## 2. 테이블 관리 (Table Management) 개선사항 + +### 2.1 테이블 기본 정보 선택 기능 +**현재 문제**: 테이블 기본 정보를 사용자가 선택할 수 없음 + +**요구사항**: +- 테이블 생성/수정 시 다음 정보를 선택 가능해야 함: + - 테이블 타입 (마스터/트랜잭션/코드) + - 카테고리 + - 로그 사용 여부 + - 버전 관리 여부 + - 소프트 삭제 여부 + +**구현 방안**: +- `TableManagement.tsx`에 선택 UI 추가 +- `CREATE TABLE` DDL 자동 생성 시 옵션 반영 + +**관련 파일**: +- `frontend/components/table/TableManagement.tsx` +- `backend-node/src/controllers/tableController.ts` + +--- + +### 2.2 컬럼 추가 기능 +**현재 문제**: 기존 테이블에 새 컬럼을 추가하는 기능 부족 + +**요구사항**: +- 테이블 수정 시 컬럼을 동적으로 추가할 수 있어야 함 +- `ALTER TABLE ADD COLUMN` DDL 자동 생성 +- 컬럼 순서 조정 기능 + +**구현 방안**: +```typescript +// 컬럼 추가 API +POST /api/table-management/tables/:tableName/columns +{ + "columnName": "new_column", + "dataType": "VARCHAR(100)", + "nullable": true, + "defaultValue": null +} +``` + +**관련 파일**: +- `frontend/components/table/TableManagement.tsx` +- `backend-node/src/controllers/tableController.ts` +- `backend-node/src/services/ddlExecutionService.ts` + +--- + +### 2.3 테이블 복제 기능 +**현재 문제**: 기존 테이블의 구조를 재사용하기 어려움 + +**요구사항**: +- 기존 테이블을 복제하여 새 테이블 생성 +- 다음 정보를 복사: + - 컬럼 구조 (이름, 타입, 제약조건) + - 인덱스 정의 + - 외래키 관계 (선택적) +- 데이터는 복사하지 않음 (구조만) + +**구현 방안**: +```typescript +// 테이블 복제 API +POST /api/table-management/tables/:sourceTableName/clone +{ + "newTableName": "cloned_table", + "includeIndexes": true, + "includeForeignKeys": false, + "copyData": false +} +``` + +**구현 단계**: +1. 원본 테이블 정보 조회 (INFORMATION_SCHEMA) +2. DDL 스크립트 생성 +3. 새 테이블 생성 +4. 인덱스 및 제약조건 추가 +5. 감사 로그 기록 + +**관련 파일**: +- `frontend/components/table/TableManagement.tsx` +- `backend-node/src/controllers/tableController.ts` +- `backend-node/src/services/ddlExecutionService.ts` + +**참고 문서**: +- `/Users/kimjuseok/ERP-node/테이블_복제_기능_구현_계획서.md` + +--- + +### 2.4 채번 Rule 관리 기능 +**현재 문제**: 자동 채번 규칙을 사용자가 관리할 수 없음 + +**요구사항**: +- 채번 규칙 생성/수정/삭제 UI +- 규칙 형식: + - 접두사 (예: "PROD-") + - 날짜 포맷 (예: "YYYYMMDD") + - 일련번호 자릿수 (예: 5자리 → 00001) + - 구분자 (예: "-") +- 예시: `PROD-20251103-00001` + +**구현 방안**: +```typescript +interface NumberingRule { + id: string; + ruleName: string; + prefix: string; + dateFormat?: "YYYY" | "YYYYMM" | "YYYYMMDD" | "YYYYMMDD-HH"; + sequenceDigits: number; + separator: string; + resetPeriod: "none" | "daily" | "monthly" | "yearly"; + currentSequence: number; + tableName: string; + columnName: string; +} +``` + +**관련 파일**: +- `frontend/components/table/NumberingRuleManagement.tsx` (신규 생성) +- `backend-node/src/controllers/numberingRuleController.ts` (신규 생성) +- `backend-node/src/services/numberingRuleService.ts` (신규 생성) + +--- + +## 3. 제어 관리 (Flow Management) 개선사항 + +### 3.1 제목 클릭 시 노드 선택 해제 +**현재 문제**: 제목을 입력할 때 백스페이스를 누르면 노드가 삭제됨 + +**요구사항**: +- 제목(플로우명) 입력란 클릭 시 노드 선택이 해제되어야 함 +- 백스페이스 키가 텍스트 입력으로만 작동해야 함 + +**구현 방안**: +```typescript +const handleTitleClick = (e: React.MouseEvent) => { + e.stopPropagation(); // 이벤트 전파 중지 + setSelectedNodes([]); // 노드 선택 해제 +}; + +const handleTitleKeyDown = (e: React.KeyboardEvent) => { + e.stopPropagation(); // 백스페이스 키가 노드 삭제로 전파되지 않도록 +}; + + setFlowName(e.target.value)} +/> +``` + +**관련 파일**: +- `frontend/components/flow/FlowDesigner.tsx` +- `frontend/components/flow/FlowCanvas.tsx` + +--- + +## 4. 우선순위 및 구현 일정 + +### 높음 (즉시 수정 필요) +1. **되돌리기 단축키 에러 수정** - 기능 오류 +2. **수정 모달 자동 닫기** - 사용자 경험 저해 +3. **제어관리 제목 입력 문제** - 데이터 손실 위험 +4. **숫자 천 단위 콤마 표시** - 가독성 문제 + +### 중간 (2주 내 완료) +5. **리스트 컬럼 Width 조절** +6. **리스트 헤더 스타일 개선** +7. **텍스트 줄바꿈 문제 방지** +8. **테이블 Align 조절** +9. **컬럼 추가 기능** + +### 낮음 (기능 추가) +10. **테이블 기본 정보 선택** +11. **테이블 복제 기능** +12. **Drilldown UI 개선** +13. **채번 Rule 관리** + +--- + +## 5. 테스트 계획 + +각 개선사항 완료 시 다음을 확인: + +### 기능 테스트 +- [ ] 새 기능이 정상 작동함 +- [ ] 기존 기능에 영향 없음 +- [ ] 에러 처리가 적절함 + +### 사용자 경험 테스트 +- [ ] UI가 직관적임 +- [ ] 반응 속도가 빠름 +- [ ] 모바일/태블릿 대응 + +### 성능 테스트 +- [ ] 대량 데이터 처리 시 성능 저하 없음 +- [ ] 메모리 누수 없음 + +--- + +## 6. 참고 문서 + +- [화면관리 시스템 현황](화면관리_및_테이블관리_개선사항_목록.md) +- [테이블 복제 기능 계획서](테이블_복제_기능_구현_계획서.md) +- [Shadcn/ui 레이아웃 패턴](docs/shadcn-ui-레이아웃-패턴-분석-보고서.md) + +--- + +## 변경 이력 + +| 날짜 | 작성자 | 내용 | +|------|--------|------| +| 2025-11-03 | 개발팀 | 초안 작성 | +