From 4614210aa9878922cfa1e424ce677ef893a1b6b2 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 29 Sep 2025 13:31:40 +0000 Subject: (대표님) 구매 권한설정, data room 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/vendor/partners-bidding-list.tsx | 4 +- lib/forms/sedp-actions.ts | 59 +- lib/mail/templates/custom-rfq-invitation.hbs | 332 ++++++++++ lib/permissions/permission-group-actions.ts | 9 +- .../permission-group-assignment-actions.ts | 496 ++++++++++++++ lib/permissions/permission-settings-actions.ts | 54 +- lib/permissions/service.ts | 88 ++- lib/project-doc-templates/service.ts | 485 ++++++++++++++ .../table/add-project-doc-template-dialog.tsx | 642 ++++++++++++++++++ .../table/doc-template-table.tsx | 716 +++++++++++++++++++++ .../table/project-doc-template-editor.tsx | 645 +++++++++++++++++++ .../table/template-detail-dialog.tsx | 121 ++++ .../table/template-edit-sheet.tsx | 305 +++++++++ lib/project-doc-templates/validations.ts | 31 + 14 files changed, 3951 insertions(+), 36 deletions(-) create mode 100644 lib/mail/templates/custom-rfq-invitation.hbs create mode 100644 lib/permissions/permission-group-assignment-actions.ts create mode 100644 lib/project-doc-templates/service.ts create mode 100644 lib/project-doc-templates/table/add-project-doc-template-dialog.tsx create mode 100644 lib/project-doc-templates/table/doc-template-table.tsx create mode 100644 lib/project-doc-templates/table/project-doc-template-editor.tsx create mode 100644 lib/project-doc-templates/table/template-detail-dialog.tsx create mode 100644 lib/project-doc-templates/table/template-edit-sheet.tsx create mode 100644 lib/project-doc-templates/validations.ts (limited to 'lib') diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index fc3cd1f2..0f68ed68 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -38,10 +38,10 @@ export function PartnersBiddingList({ promises }: PartnersBiddingListProps) { case 'view': // 사전견적 요청 상태에서는 상세보기 제한 - if (bidding.status === 'request_for_quotation') { + if (bidding.status === 'request_for_quotation' || bidding.status === 'received_quotation' || bidding.status === 'set_target_price') { toast({ title: '접근 제한', - description: '사전견적 요청 상태에서는 본입찰을 이용할 수 없습니다.', + description: '사전견적 상태에서는 본입찰을 이용할 수 없습니다.', variant: 'destructive', }) return diff --git a/lib/forms/sedp-actions.ts b/lib/forms/sedp-actions.ts index 24ae2e66..d20fb9fc 100644 --- a/lib/forms/sedp-actions.ts +++ b/lib/forms/sedp-actions.ts @@ -74,7 +74,7 @@ export async function fetchTagDataFromSEDP( export async function fetchTemplateFromSEDP( projectCode: string, formCode: string -): Promise { +): Promise { try { // Get the token const apiKey = await getSEDPToken(); @@ -82,9 +82,8 @@ export async function fetchTemplateFromSEDP( // Define the API base URL const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; - // Make the API call - const response = await fetch( - `${SEDP_API_BASE_URL}/Template/GetByRegisterID`, + const responseAdapter = await fetch( + `${SEDP_API_BASE_URL}/AdapterDataMapping/GetByToolID`, { method: 'POST', headers: { @@ -94,23 +93,59 @@ export async function fetchTemplateFromSEDP( 'ProjectNo': projectCode }, body: JSON.stringify({ - WithContent: true, ProjectNo: projectCode, - REG_TYPE_ID: formCode + "TOOL_ID": "eVCP" }) } ); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`SEDP Template API request failed: ${response.status} ${response.statusText} - ${errorText}`); + if (!responseAdapter.ok) { + throw new Error(`새 레지스터 요청 실패: ${responseAdapter.status} ${responseAdapter.statusText}`); } - const data = await response.json(); - return data as SEDPTemplateData; + const dataAdapter = await responseAdapter.json(); + const templateList = dataAdapter.find(v => v.REG_TYPE_ID === formCode)?.MAP_TMPLS || []; + + // 각 TMPL_ID에 대해 API 호출 + const templatePromises = templateList.map(async (tmplId: string) => { + const response = await fetch( + `${SEDP_API_BASE_URL}/Template/GetByID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + WithContent: true, + ProjectNo: projectCode, + TMPL_ID: tmplId + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP Template API request failed for TMPL_ID ${tmplId}: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data as SEDPTemplateData; + }); + + // 모든 API 호출을 병렬로 실행하고 결과를 수집 + const templates = await Promise.all(templatePromises); + + // null이나 undefined가 아닌 값들만 필터링 (응답이 없는 경우 제외) + const validTemplates = templates.filter(template => template != null); + + return validTemplates; + } catch (error: unknown) { console.error('Error calling SEDP Template API:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to fetch template from SEDP API: ${errorMessage}`); } -} +} \ No newline at end of file diff --git a/lib/mail/templates/custom-rfq-invitation.hbs b/lib/mail/templates/custom-rfq-invitation.hbs new file mode 100644 index 00000000..9303035b --- /dev/null +++ b/lib/mail/templates/custom-rfq-invitation.hbs @@ -0,0 +1,332 @@ + + + + + + RFQ 견적 요청 + + + +
+ +
+ +
+ + +
+ 안녕하세요.

+ 귀사 일익 번창하심을 기원합니다.

+ 폐사는 귀사와 표제 프로젝트의 품목에 대한 거래를 위해
+ 다음 품목에 대해 귀사의 견적 제출을 요청하오니 아래 내용 및 당사 시스템 접속하시어
+ 견적의 상세 내용 확인하신 뒤 견적마감일까지 견적 제출 바랍니다.

+ 귀사의 견적은 반드시 견적마감일 이전에 폐사로 제출되어야 하며,
+ 견적마감일 전 별도의 지연 통보 없이 미제출될 경우에는 대상에서 제외될 수 있습니다. +
+ + +
+
{{rfqTitle}}
+
RFQ No: {{rfqCode}}
+
+ + +
+
1. 프로젝트 정보
+
+
+
프로젝트정보
+
[{{projectCode}}] {{projectName}}
+
+
+
고객정보
+
{{customerName}} ({{customerCode}})
+
+
+
선종
+
{{shipType}} ({{shipClass}})
+
+
+
척수
+
{{shipCount}}척
+
+
+
선급
+
{{projectFlag}}
+
+
+
국적
+
{{flag}}
+
+
+
계약발효일
+
{{contractStartDate}} - {{contractEndDate}}
+
+
+
Key Event
+
(S/C) {{scDate}} - (D/L) {{dlDate}}
+
+
+
+ + +
+
2. 견적요청 정보
+
+
+
PKG 정보
+
[{{packageNo}}] {{packageName}}
+
+
+
자재그룹 정보
+
[{{materialGroup}}] {{materialGroupDesc}}
+
+
+
품목
+
{{#if itemCode}}{{itemCode}} - {{/if}}{{itemName}}
+
+
+
품목 수
+
{{itemCount}}개
+
+
+
PR 번호
+
{{#if prNumber}}{{prNumber}}{{else}}해당없음{{/if}}
+
+
+
PR 발행일
+
{{#if prIssueDate}}{{prIssueDate}}{{else}}해당없음{{/if}}
+
+
+ +
+ Warranty {{warrantyDescription}}
+ {{repairDescription}}
+ {{totalWarrantyDescription}} +
+ +
+ 필수제출정보 +
    + {{#each requiredDocuments}} +
  • {{this}}
  • + {{/each}} +
+
+
+ + +
+
3. 필수 기본계약
+
+ {{#if contractRequirements.hasNda}}{{contractRequirements.ndaDescription}}{{/if}} + {{#if contractRequirements.hasGeneralGtc}}{{contractRequirements.generalGtcDescription}}{{/if}} + {{#if contractRequirements.hasProjectGtc}}{{contractRequirements.projectGtcDescription}}{{/if}} + {{#if contractRequirements.hasAgreement}}{{contractRequirements.agreementDescription}}{{/if}} +
+ +
+ + +
+
4. 유의사항
+
    +
  • 발주자는 최저가 견적을 제출하지 않은 협력사를 선정할 수 있으며, 견적의 일부 또는 전부를 승인하거나 거절할 수 있고 거부하는 경우 별도 통보할 의무가 없다. 또한, 최종 탈락 사실을 통보할 의무도 없다.
  • +
  • 협력사는 견적 제출을 위해 소요되는 비용 일체를 부담하며, 최종 계약자로 선정되지 못한 경우에도 발주자에게 보상을 청구할 수 없다. 또한, 선주 승인조건으로 선정된 협력사는 반드시 선주 승인을 득한 후 작업에 착수하여야 하며, 승인 거절 시 계약은 미 체결, 해제될 수 있고 이 경우 협력사는 어떠한 보상도 청구할 수 없다.
  • +
+
+ + +
+ 이번 기회를 통하여 귀사와의 협업으로 다가올 미래 조선/해양산업 시장에서 함께 성장해 나갈 수 있기를 기대합니다. +
+ + + +
+ + diff --git a/lib/permissions/permission-group-actions.ts b/lib/permissions/permission-group-actions.ts index 51e3c2c0..474dc21b 100644 --- a/lib/permissions/permission-group-actions.ts +++ b/lib/permissions/permission-group-actions.ts @@ -117,10 +117,13 @@ export async function updatePermissionGroup(id: number, data: any) { // 권한 그룹 삭제 export async function deletePermissionGroup(id: number) { - const currentUser = await getCurrentUser(); - if (!currentUser) throw new Error("Unauthorized"); + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) - if (!await checkUserPermission(currentUser.id, "admin.permissions.manage")) { + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { throw new Error("권한 관리 권한이 없습니다."); } diff --git a/lib/permissions/permission-group-assignment-actions.ts b/lib/permissions/permission-group-assignment-actions.ts new file mode 100644 index 00000000..d1311559 --- /dev/null +++ b/lib/permissions/permission-group-assignment-actions.ts @@ -0,0 +1,496 @@ +// app/actions/permission-group-assignment-actions.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and, inArray, sql, ne, notInArray, or } from "drizzle-orm"; +import { + permissionGroups, + permissionGroupMembers, + permissions, + rolePermissions, + userPermissions, + roles, + users, + userRoles, + permissionAuditLogs +} from "@/db/schema"; +import { checkUserPermission } from "./service"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +// 권한 그룹 할당 정보 조회 +export async function getPermissionGroupAssignments(groupId?: number) { + try { + if (!groupId) { + // 모든 그룹 목록 반환 + const groups = await db + .select({ + id: permissionGroups.id, + groupKey: permissionGroups.groupKey, + name: permissionGroups.name, + description: permissionGroups.description, + domain: permissionGroups.domain, + isActive: permissionGroups.isActive, + permissionCount: sql`count(distinct ${permissionGroupMembers.permissionId})`.mapWith(Number), + }) + .from(permissionGroups) + .leftJoin(permissionGroupMembers, eq(permissionGroupMembers.groupId, permissionGroups.id)) + .where(eq(permissionGroups.isActive, true)) + .groupBy(permissionGroups.id) + .orderBy(permissionGroups.name); + + return { groups }; + } + + // 특정 그룹의 할당 정보 조회 + // 그룹에 속한 모든 권한 ID 조회 + const groupPermissionIds = await db + .select({ permissionId: permissionGroupMembers.permissionId }) + .from(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + const permissionIds = groupPermissionIds.map(p => p.permissionId); + + if (permissionIds.length === 0) { + return { roles: [], users: [] }; + } + + // 해당 그룹의 권한들이 할당된 역할 조회 + const assignedRoles = await db + .selectDistinct({ + id: roles.id, + name: roles.name, + domain: roles.domain, + grantedAt: rolePermissions.grantedAt, + grantedBy: rolePermissions.grantedBy, + }) + .from(rolePermissions) + .innerJoin(roles, eq(roles.id, rolePermissions.roleId)) + .where( + and( + eq(rolePermissions.permissionGroupId, groupId), + eq(rolePermissions.isActive, true) + ) + ); + + // 역할별 사용자 수 조회 + const rolesWithUserCount = await Promise.all( + assignedRoles.map(async (role) => { + const userCount = await db + .select({ count: sql`count(*)`.mapWith(Number) }) + .from(userRoles) + .where(eq(userRoles.roleId, role.id)); + + // grantedBy가 userId인 경우 사용자 정보 조회 + let assignedBy = 'system'; + if (role.grantedBy) { + const [user] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, role.grantedBy)); + if (user) assignedBy = user.name; + } + + return { + ...role, + userCount: userCount[0]?.count || 0, + assignedAt: role.grantedAt, + assignedBy + }; + }) + ); + + // 해당 그룹의 권한들이 직접 할당된 사용자 조회 + const assignedUsers = await db + .selectDistinct({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + grantedAt: userPermissions.grantedAt, + grantedBy: userPermissions.grantedBy, + }) + .from(userPermissions) + .innerJoin(users, eq(users.id, userPermissions.userId)) + .where( + and( + eq(userPermissions.permissionGroupId, groupId), + eq(userPermissions.isActive, true), + eq(userPermissions.isGrant, true) + ) + ); + + // 사용자별 회사 정보 및 할당자 정보 추가 + const usersWithDetails = await Promise.all( + assignedUsers.map(async (user) => { + // 회사 정보는 companyId로 조회 (vendors 테이블 필요) + let assignedBy = 'system'; + if (user.grantedBy) { + const [grantUser] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, user.grantedBy)); + if (grantUser) assignedBy = grantUser.name; + } + + return { + ...user, + companyName: null, // 필요시 vendors 테이블 조인 + assignedAt: user.grantedAt, + assignedBy + }; + }) + ); + + return { + roles: rolesWithUserCount, + users: usersWithDetails + }; + } catch (error) { + console.error('Failed to get permission group assignments:', error); + throw new Error('권한 그룹 할당 정보 조회에 실패했습니다.'); + } +} + +// 역할 검색 (그룹에 아직 할당되지 않은) +export async function searchRoles(groupId: number) { + try { + // 이미 해당 그룹이 할당된 역할 ID 조회 + const assignedRoleIds = await db + .selectDistinct({ roleId: rolePermissions.roleId }) + .from(rolePermissions) + .where( + and( + eq(rolePermissions.permissionGroupId, groupId), + eq(rolePermissions.isActive, true) + ) + ); + + const assignedIds = assignedRoleIds.map(r => r.roleId); + + // 할당되지 않은 역할 조회 + const availableRoles = await db + .select({ + id: roles.id, + name: roles.name, + domain: roles.domain, + }) + .from(roles) + .where( + assignedIds.length > 0 + ? notInArray(roles.id, assignedIds) + : undefined + ); + + // 역할별 사용자 수 추가 + const rolesWithUserCount = await Promise.all( + availableRoles.map(async (role) => { + const userCount = await db + .select({ count: sql`count(*)`.mapWith(Number) }) + .from(userRoles) + .where(eq(userRoles.roleId, role.id)); + + return { + ...role, + userCount: userCount[0]?.count || 0 + }; + }) + ); + + return rolesWithUserCount; + } catch (error) { + console.error('Failed to search roles:', error); + throw new Error('역할 검색에 실패했습니다.'); + } +} + +// 사용자 검색 (그룹에 아직 할당되지 않은) +export async function searchUsers(query: string, groupId: number) { + try { + // 이미 해당 그룹이 할당된 사용자 ID 조회 + const assignedUserIds = await db + .selectDistinct({ userId: userPermissions.userId }) + .from(userPermissions) + .where( + and( + eq(userPermissions.permissionGroupId, groupId), + eq(userPermissions.isActive, true), + eq(userPermissions.isGrant, true) + ) + ); + + const assignedIds = assignedUserIds.map(u => u.userId); + + // 할당되지 않은 사용자 검색 + const availableUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + }) + .from(users) + .where( + and( + or( + sql`${users.name} ILIKE ${`%${query}%`}`, + sql`${users.email} ILIKE ${`%${query}%`}` + ), + eq(users.isActive, true), + assignedIds.length > 0 + ? notInArray(users.id, assignedIds) + : undefined + ) + ) + .limit(20); + + return availableUsers.map(user => ({ + ...user, + companyName: null // 필요시 vendors 테이블 조인 + })); + } catch (error) { + console.error('Failed to search users:', error); + throw new Error('사용자 검색에 실패했습니다.'); + } +} + +// 그룹을 역할에 할당 +export async function assignGroupToRoles(groupId: number, roleIds: number[]) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + const currentUserId = Number(session.user.id); + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + try { + // 그룹에 속한 모든 권한 ID 조회 + const groupPermissions = await db + .select({ permissionId: permissionGroupMembers.permissionId }) + .from(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + if (groupPermissions.length === 0) { + throw new Error("그룹에 권한이 없습니다."); + } + + await db.transaction(async (tx) => { + for (const roleId of roleIds) { + // 각 역할에 대해 그룹의 모든 권한 할당 + for (const { permissionId } of groupPermissions) { + // 기존 할당 확인 (중복 방지) + const existing = await tx + .select() + .from(rolePermissions) + .where( + and( + eq(rolePermissions.roleId, roleId), + eq(rolePermissions.permissionId, permissionId), + eq(rolePermissions.permissionGroupId, groupId) + ) + ) + .limit(1); + + if (existing.length === 0) { + await tx.insert(rolePermissions).values({ + roleId, + permissionId, + permissionGroupId: groupId, + grantedBy: currentUserId, + grantedAt: new Date(), + isActive: true + }); + } + } + + // 감사 로그 추가 + await tx.insert(permissionAuditLogs).values({ + targetType: 'role', + targetId: roleId, + permissionGroupId: groupId, + action: 'grant', + performedBy: currentUserId, + reason: `권한 그룹 ${groupId} 할당` + }); + } + }); + + return { success: true, count: roleIds.length }; + } catch (error) { + console.error('Failed to assign group to roles:', error); + throw new Error('역할에 권한 그룹 할당에 실패했습니다.'); + } +} + +// 그룹을 사용자에 할당 +export async function assignGroupToUsers(groupId: number, userIds: number[]) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + const currentUserId = Number(session.user.id); + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + try { + // 그룹에 속한 모든 권한 ID 조회 + const groupPermissions = await db + .select({ permissionId: permissionGroupMembers.permissionId }) + .from(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + if (groupPermissions.length === 0) { + throw new Error("그룹에 권한이 없습니다."); + } + + await db.transaction(async (tx) => { + for (const userId of userIds) { + // 각 사용자에 대해 그룹의 모든 권한 할당 + for (const { permissionId } of groupPermissions) { + // 기존 할당 확인 (중복 방지) + const existing = await tx + .select() + .from(userPermissions) + .where( + and( + eq(userPermissions.userId, userId), + eq(userPermissions.permissionId, permissionId) + ) + ) + .limit(1); + + if (existing.length === 0) { + await tx.insert(userPermissions).values({ + userId, + permissionId, + permissionGroupId: groupId, + isGrant: true, + grantedBy: currentUserId, + grantedAt: new Date(), + isActive: true + }); + } else if (existing[0].permissionGroupId !== groupId) { + // 다른 그룹으로 할당되어 있다면 업데이트 + await tx.update(userPermissions) + .set({ + permissionGroupId: groupId, + grantedBy: currentUserId, + grantedAt: new Date() + }) + .where( + and( + eq(userPermissions.userId, userId), + eq(userPermissions.permissionId, permissionId) + ) + ); + } + } + + // 감사 로그 추가 + await tx.insert(permissionAuditLogs).values({ + targetType: 'user', + targetId: userId, + permissionGroupId: groupId, + action: 'grant', + performedBy: currentUserId, + reason: `권한 그룹 ${groupId} 할당` + }); + } + }); + + return { success: true, count: userIds.length }; + } catch (error) { + console.error('Failed to assign group to users:', error); + throw new Error('사용자에게 권한 그룹 할당에 실패했습니다.'); + } +} + +// 역할에서 그룹 제거 +export async function removeGroupFromRole(groupId: number, roleId: number) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + const currentUserId = Number(session.user.id); + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + try { + await db.transaction(async (tx) => { + // 해당 그룹으로 할당된 모든 권한 제거 + await tx.delete(rolePermissions) + .where( + and( + eq(rolePermissions.roleId, roleId), + eq(rolePermissions.permissionGroupId, groupId) + ) + ); + + // 감사 로그 추가 + await tx.insert(permissionAuditLogs).values({ + targetType: 'role', + targetId: roleId, + permissionGroupId: groupId, + action: 'revoke', + performedBy: currentUserId, + reason: `권한 그룹 ${groupId} 제거` + }); + }); + + return { success: true }; + } catch (error) { + console.error('Failed to remove group from role:', error); + throw new Error('역할에서 권한 그룹 제거에 실패했습니다.'); + } +} + +// 사용자에서 그룹 제거 +export async function removeGroupFromUser(groupId: number, userId: number) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + const currentUserId = Number(session.user.id); + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + try { + await db.transaction(async (tx) => { + // 해당 그룹으로 할당된 모든 권한 제거 + await tx.delete(userPermissions) + .where( + and( + eq(userPermissions.userId, userId), + eq(userPermissions.permissionGroupId, groupId) + ) + ); + + // 감사 로그 추가 + await tx.insert(permissionAuditLogs).values({ + targetType: 'user', + targetId: userId, + permissionGroupId: groupId, + action: 'revoke', + performedBy: currentUserId, + reason: `권한 그룹 ${groupId} 제거` + }); + }); + + return { success: true }; + } catch (error) { + console.error('Failed to remove group from user:', error); + throw new Error('사용자에서 권한 그룹 제거에 실패했습니다.'); + } +} \ No newline at end of file diff --git a/lib/permissions/permission-settings-actions.ts b/lib/permissions/permission-settings-actions.ts index 5d04a1d3..bb82b456 100644 --- a/lib/permissions/permission-settings-actions.ts +++ b/lib/permissions/permission-settings-actions.ts @@ -12,6 +12,37 @@ import { import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { checkUserPermission } from "./service"; +import fs from 'fs/promises'; +import path from 'path'; + +// i18n 번역 파일을 읽어오는 헬퍼 함수 +async function getTranslations(locale: string = 'ko') { + try { + const filePath = path.join(process.cwd(), 'i18n', 'locales', locale, 'menu.json'); + const fileContent = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(fileContent); + } catch (error) { + console.error(`Failed to load translations for ${locale}:`, error); + return {}; + } +} + +// 중첩된 객체에서 키로 값을 가져오는 헬퍼 함수 +function getNestedValue(obj: any, key: string): string { + const keys = key.split('.'); + let value = obj; + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = value[k]; + } else { + return key; // 키를 찾지 못하면 원본 키를 반환 + } + } + + return typeof value === 'string' ? value : key; +} + // 모든 권한 조회 export async function getAllPermissions() { @@ -110,7 +141,11 @@ export async function deletePermission(id: number) { } // 메뉴 권한 분석 + export async function analyzeMenuPermissions() { + // 한국어 번역 파일 로드 + const translations = await getTranslations('ko'); + const menus = await db.select().from(menuAssignments); const analysis = await Promise.all( @@ -126,20 +161,26 @@ export async function analyzeMenuPermissions() { .innerJoin(permissions, eq(permissions.id, menuRequiredPermissions.permissionId)) .where(eq(menuRequiredPermissions.menuPath, menu.menuPath)); + // i18n 키를 실제 텍스트로 변환 + const menuTitleTranslated = getNestedValue(translations, menu.menuTitle); + const menuDescriptionTranslated = menu.menuDescription + ? getNestedValue(translations, menu.menuDescription) + : ''; + // 제안할 권한 생성 const suggestedPermissions = []; const resourceName = menu.menuPath.split('/').pop() || 'unknown'; - // 기본 메뉴 접근 권한 + // 기본 메뉴 접근 권한 (번역된 제목 사용) suggestedPermissions.push({ permissionKey: `${resourceName}.menu_access`, - name: `${menu.menuTitle} 접근`, + name: `${menuTitleTranslated} 접근`, permissionType: "menu_access", action: "access", scope: "assigned", }); - // CRUD 권한 제안 + // CRUD 권한 제안 (번역된 제목 사용) const actions = [ { action: "view", name: "조회", type: "data_read" }, { action: "create", name: "생성", type: "data_write" }, @@ -150,7 +191,7 @@ export async function analyzeMenuPermissions() { actions.forEach(({ action, name, type }) => { suggestedPermissions.push({ permissionKey: `${resourceName}.${action}`, - name: `${menu.menuTitle} ${name}`, + name: `${menuTitleTranslated} ${name}`, permissionType: type, action, scope: "assigned", @@ -159,7 +200,9 @@ export async function analyzeMenuPermissions() { return { menuPath: menu.menuPath, - menuTitle: menu.menuTitle, + menuTitle: menuTitleTranslated, // 번역된 제목 + menuTitleKey: menu.menuTitle, // 원본 i18n 키 (필요한 경우) + menuDescription: menuDescriptionTranslated, // 번역된 설명 domain: menu.domain, existingPermissions: existing, suggestedPermissions: suggestedPermissions.filter( @@ -172,6 +215,7 @@ export async function analyzeMenuPermissions() { return analysis; } + // 메뉴 기반 권한 생성 export async function generateMenuPermissions( permissionsToCreate: Array<{ diff --git a/lib/permissions/service.ts b/lib/permissions/service.ts index 3ef1ff04..b3e6b4bc 100644 --- a/lib/permissions/service.ts +++ b/lib/permissions/service.ts @@ -3,7 +3,7 @@ "use server"; import db from "@/db/db"; -import { eq, and, inArray, or, ilike } from "drizzle-orm"; +import { eq, and, inArray, or, ilike, sql } from "drizzle-orm"; import { permissions, rolePermissions, @@ -70,21 +70,58 @@ export async function assignPermissionsToRole( // 역할의 권한 목록 조회 +// 역할 권한 조회 (기존 함수) export async function getRolePermissions(roleId: number) { - const allPermissions = await db.select().from(permissions) - .where(eq(permissions.isActive, true)); - - const rolePerms = await db.select({ - permissionId: rolePermissions.permissionId, - }) + try { + // 역할에 할당된 권한 조회 + const assignedPermissions = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + resource: permissions.resource, + action: permissions.action, + permissionType: permissions.permissionType, + scope: permissions.scope, + menuPath: permissions.menuPath, + }) .from(rolePermissions) - .where(eq(rolePermissions.roleId, roleId)); - - return { + .innerJoin(permissions, eq(permissions.id, rolePermissions.permissionId)) + .where( + and( + eq(rolePermissions.roleId, roleId), + eq(rolePermissions.isActive, true) + ) + ); + + // 모든 활성 권한 조회 + const allPermissions = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + resource: permissions.resource, + action: permissions.action, + permissionType: permissions.permissionType, + scope: permissions.scope, + menuPath: permissions.menuPath, + }) + .from(permissions) + .where(eq(permissions.isActive, true)) + .orderBy(permissions.resource, permissions.name); + + return { permissions: allPermissions, - assignedPermissionIds: rolePerms.map(rp => rp.permissionId), - }; -} + assignedPermissionIds: assignedPermissions.map(p => p.id) + }; + } catch (error) { + console.error('Failed to get role permissions:', error); + throw new Error('역할 권한 조회에 실패했습니다.'); + } + } + // 권한 체크 함수 export async function checkUserPermission( @@ -431,4 +468,27 @@ export async function updateMenuPermissions( ); } }); -} \ No newline at end of file +} + +// 역할 목록 조회 +export async function getRoles() { + try { + const rolesData = await db + .select({ + id: roles.id, + name: roles.name, + domain: roles.domain, + description: roles.description, + userCount: sql`count(distinct ${userRoles.userId})`.mapWith(Number), + }) + .from(roles) + .leftJoin(userRoles, eq(userRoles.roleId, roles.id)) + .groupBy(roles.id) + .orderBy(roles.domain, roles.name); + + return rolesData; + } catch (error) { + console.log('Failed to get roles:', error); + throw new Error('역할 목록 조회에 실패했습니다.'); + } + } \ No newline at end of file diff --git a/lib/project-doc-templates/service.ts b/lib/project-doc-templates/service.ts new file mode 100644 index 00000000..a5bccce5 --- /dev/null +++ b/lib/project-doc-templates/service.ts @@ -0,0 +1,485 @@ +// lib/project-doc-templates/service.ts +"use server"; + +import db from "@/db/db"; +import { projectDocTemplates, projectDocTemplateUsage, projects, type NewProjectDocTemplate, type DocTemplateVariable } from "@/db/schema"; +import { eq, and, desc, asc, isNull, sql, inArray, count, or, ilike } from "drizzle-orm"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { GetDOCTemplatesSchema } from "./validations"; +import { filterColumns } from "@/lib/filter-columns"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +// 기본 변수 정의 +const DEFAULT_VARIABLES: DocTemplateVariable[] = [ + { + name: "document_number", + displayName: "문서번호", + type: "text", + required: true, + description: "문서 고유 번호", + }, + { + name: "project_code", + displayName: "프로젝트 코드", + type: "text", + required: true, + description: "프로젝트 식별 코드", + }, + { + name: "project_name", + displayName: "프로젝트명", + type: "text", + required: true, + description: "프로젝트 이름", + }, + { + name: "created_date", + displayName: "작성일", + type: "date", + required: false, + defaultValue: "{{today}}", + description: "문서 작성 날짜", + }, + { + name: "author_name", + displayName: "작성자", + type: "text", + required: false, + description: "문서 작성자 이름", + }, + { + name: "department", + displayName: "부서명", + type: "text", + required: false, + description: "작성 부서", + }, +]; + + +export async function getProjectDocTemplates( + input: GetDOCTemplatesSchema +) { + try { + const offset = (input.page - 1) * input.perPage; + + // 고급 필터 조건 + const advancedWhere = filterColumns({ + table: projectDocTemplates, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 조건 (중복 제거됨) + let globalWhere: SQL | undefined = undefined; + if (input.search?.trim()) { + const searchTerm = `%${input.search.trim()}%`; + globalWhere = or( + ilike(projectDocTemplates.templateName, searchTerm), + ilike(projectDocTemplates.templateCode, searchTerm), + ilike(projectDocTemplates.projectCode, searchTerm), + ilike(projectDocTemplates.projectName, searchTerm), + ilike(projectDocTemplates.documentType, searchTerm), + ilike(projectDocTemplates.fileName, searchTerm), // 중복 제거 + ilike(projectDocTemplates.createdByName, searchTerm), + ilike(projectDocTemplates.updatedByName, searchTerm), + ilike(projectDocTemplates.status, searchTerm), + ilike(projectDocTemplates.description, searchTerm) // 추가 고려 + ); + } + + // WHERE 조건 결합 + const whereCondition = and(advancedWhere, globalWhere, eq(projectDocTemplates.isLatest,true)); + + // 정렬 조건 (타입 안정성 개선) + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + const column = projectDocTemplates[item.id as keyof typeof projectDocTemplates]; + if (!column) { + console.warn(`Invalid sort column: ${item.id}`); + return null; + } + return item.desc ? desc(column) : asc(column); + }).filter(Boolean) as SQL[] + : [desc(projectDocTemplates.createdAt)]; + + // 데이터 조회 (프로젝트 정보 조인 추가 고려) + const [data, totalCount] = await Promise.all([ + db + .select({ + ...projectDocTemplates, + // 프로젝트 정보가 필요한 경우 + // project: projects + }) + .from(projectDocTemplates) + // .leftJoin(projects, eq(projectDocTemplates.projectId, projects.id)) + .where(whereCondition) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset), + db + .select({ count: count() }) + .from(projectDocTemplates) + .where(whereCondition) + .then((res) => res[0]?.count ?? 0), + ]); + + const pageCount = Math.ceil(totalCount / input.perPage); + + return { + data, + pageCount, + totalCount, + }; + } catch (error) { + console.error("Failed to fetch project doc templates:", error); + throw new Error("템플릿 목록을 불러오는데 실패했습니다."); + } +} + +// 템플릿 생성 +export async function createProjectDocTemplate(data: { + templateName: string; + templateCode?: string; + description?: string; + projectId?: number; + templateType: "PROJECT" | "COMPANY_WIDE"; + documentType: string; + filePath: string; + fileName: string; + fileSize?: number; + mimeType?: string; + variables?: DocTemplateVariable[]; + isPublic?: boolean; + requiresApproval?: boolean; + createdBy?: string; +}) { + try { + // 템플릿 코드 자동 생성 (없을 경우) + const templateCode = data.templateCode || `TPL_${Date.now()}`; + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + + // 프로젝트 정보 조회 (projectId가 있는 경우) + let projectInfo = null; + if (data.projectId) { + projectInfo = await db + .select() + .from(projects) + .where(eq(projects.id, data.projectId)) + .then((res) => res[0]); + } + + // 변수 정보 설정 (기본 변수 + 사용자 정의 변수) + const variables = [...DEFAULT_VARIABLES, ...(data.variables || [])]; + const requiredVariables = variables + .filter((v) => v.required) + .map((v) => v.name); + + const newTemplate: NewProjectDocTemplate = { + templateName: data.templateName, + templateCode, + description: data.description, + projectId: data.projectId, + projectCode: projectInfo?.code, + projectName: projectInfo?.name, + templateType: data.templateType, + documentType: data.documentType, + filePath: data.filePath, + fileName: data.fileName, + fileSize: data.fileSize, + mimeType: data.mimeType, + variables, + requiredVariables, + isPublic: data.isPublic || false, + requiresApproval: data.requiresApproval || false, + status: "ACTIVE", + createdBy: Number(session.user.id), + createdByName: Number(session.user.name), + }; + + const [template] = await db + .insert(projectDocTemplates) + .values(newTemplate) + .returning(); + + revalidateTag("project-doc-templates"); + revalidatePath("/project-doc-templates"); + + return { success: true, data: template }; + } catch (error) { + console.error("Failed to create template:", error); + return { success: false, error: "템플릿 생성에 실패했습니다." }; + } +} + +// 템플릿 상세 조회 +export async function getProjectDocTemplateById(templateId: number) { + try { + const template = await db + .select({ + template: projectDocTemplates, + project: projects, + }) + .from(projectDocTemplates) + .leftJoin(projects, eq(projectDocTemplates.projectId, projects.id)) + .where(eq(projectDocTemplates.id, templateId)) + .then((res) => res[0]); + + if (!template) { + throw new Error("템플릿을 찾을 수 없습니다."); + } + + // 버전 히스토리 조회 + const versionHistory = await db + .select() + .from(projectDocTemplates) + .where( + and( + eq(projectDocTemplates.templateCode, template.template.templateCode), + isNull(projectDocTemplates.deletedAt) + ) + ) + .orderBy(desc(projectDocTemplates.version)); + + // 사용 이력 조회 (최근 10건) + const usageHistory = await db + .select() + .from(projectDocTemplateUsage) + .where(eq(projectDocTemplateUsage.templateId, templateId)) + .orderBy(desc(projectDocTemplateUsage.usedAt)) + .limit(10); + + return { + ...template.template, + project: template.project, + versionHistory, + usageHistory, + }; + } catch (error) { + console.error("Failed to fetch template details:", error); + throw new Error("템플릿 상세 정보를 불러오는데 실패했습니다."); + } +} + +// 템플릿 업데이트 +export async function updateProjectDocTemplate( + templateId: number, + data: Partial<{ + templateName: string; + description: string; + documentType: string; + variables: TemplateVariable[]; + isPublic: boolean; + requiresApproval: boolean; + status: string; + updatedBy: string; + }> +) { + try { + // 변수 정보 업데이트 시 requiredVariables도 함께 업데이트 + const updateData: any = { ...data, updatedAt: new Date() }; + + if (data.variables) { + updateData.variables = data.variables; + updateData.requiredVariables = data.variables + .filter((v) => v.required) + .map((v) => v.name); + } + + const [updated] = await db + .update(projectDocTemplates) + .set(updateData) + .where(eq(projectDocTemplates.id, templateId)) + .returning(); + + revalidateTag("project-doc-templates"); + revalidatePath("/project-doc-templates"); + + return { success: true, data: updated }; + } catch (error) { + console.error("Failed to update template:", error); + return { success: false, error: "템플릿 업데이트에 실패했습니다." }; + } +} + +// 새 버전 생성 +export async function createTemplateVersion( + templateId: number, + data: { + filePath: string; + fileName: string; + fileSize?: number; + mimeType?: string; + variables?: DocTemplateVariable[]; + createdBy?: string; + } +) { + try { + + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + + // 기존 템플릿 조회 + const existingTemplate = await db + .select() + .from(projectDocTemplates) + .where(eq(projectDocTemplates.id, templateId)) + .then((res) => res[0]); + + if (!existingTemplate) { + throw new Error("템플릿을 찾을 수 없습니다."); + } + + // 모든 버전의 isLatest를 false로 업데이트 + await db + .update(projectDocTemplates) + .set({ isLatest: false }) + .where(eq(projectDocTemplates.templateCode, existingTemplate.templateCode)); + + // 새 버전 생성 + const newVersion = existingTemplate.version + 1; + const variables = data.variables || existingTemplate.variables; + + const [newTemplate] = await db + .insert(projectDocTemplates) + .values({ + ...existingTemplate, + id: undefined, // 새 ID 자동 생성 + version: newVersion, + isLatest: true, + parentTemplateId: templateId, + filePath: data.filePath, + fileName: data.fileName, + fileSize: data.fileSize, + mimeType: data.mimeType, + variables, + requiredVariables: variables + .filter((v: DocTemplateVariable) => v.required) + .map((v: DocTemplateVariable) => v.name), + createdBy:Number(session.user.id), + creaetedByName:session.user.name, + updatedBy:Number(session.user.id), + updatedByName:session.user.name, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + revalidateTag("project-doc-templates"); + revalidatePath("/project-doc-templates"); + + return { success: true, data: newTemplate }; + } catch (error) { + console.error("Failed to create template version:", error); + return { success: false, error: "새 버전 생성에 실패했습니다." }; + } +} + +// 템플릿 삭제 (soft delete) +export async function deleteProjectDocTemplate(templateId: number) { + try { + await db + .update(projectDocTemplates) + .set({ + deletedAt: new Date(), + status: "ARCHIVED" + }) + .where(eq(projectDocTemplates.id, templateId)); + + revalidateTag("project-doc-templates"); + revalidatePath("/project-doc-templates"); + + return { success: true }; + } catch (error) { + console.error("Failed to delete template:", error); + return { success: false, error: "템플릿 삭제에 실패했습니다." }; + } +} + +// 템플릿 파일 저장 +export async function tlsaveTemplateFile( + templateId: number, + formData: FormData +) { + try { + const file = formData.get("file") as File; + if (!file) { + throw new Error("파일이 없습니다."); + } + + // 파일 저장 로직 (실제 구현 필요) + const fileName = file.name; + const filePath = `/uploads/templates/${templateId}/${fileName}`; + + // 템플릿 파일 경로 업데이트 + await db + .update(projectDocTemplates) + .set({ + filePath, + fileName, + updatedAt: new Date(), + }) + .where(eq(projectDocTemplates.id, templateId)); + + revalidateTag("project-doc-templates"); + + return { success: true, filePath }; + } catch (error) { + console.error("Failed to save template file:", error); + return { success: false, error: "파일 저장에 실패했습니다." }; + } +} + +// 사용 가능한 프로젝트 목록 조회 +export async function getAvailableProjects() { + try { + const projectList = await db + .select() + .from(projects) + // .where(eq(projects.status, "ACTIVE")) + .orderBy(projects.code); + + return projectList; + } catch (error) { + console.error("Failed to fetch projects:", error); + return []; + } +} + +// 템플릿 사용 기록 생성 +export async function recordTemplateUsage( + templateId: number, + data: { + generatedDocumentId: string; + generatedFilePath: string; + generatedFileName: string; + usedVariables: Record; + usedInProjectId?: number; + usedInProjectCode?: string; + usedBy: string; + metadata?: any; + } +) { + try { + const [usage] = await db + .insert(projectDocTemplateUsage) + .values({ + templateId, + ...data, + }) + .returning(); + + return { success: true, data: usage }; + } catch (error) { + console.error("Failed to record template usage:", error); + return { success: false, error: "사용 기록 생성에 실패했습니다." }; + } +} \ No newline at end of file diff --git a/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx b/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx new file mode 100644 index 00000000..fb36aebd --- /dev/null +++ b/lib/project-doc-templates/table/add-project-doc-template-dialog.tsx @@ -0,0 +1,642 @@ +"use client"; + +import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { toast } from "sonner"; +import { v4 as uuidv4 } from "uuid"; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone"; +import { Progress } from "@/components/ui/progress"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Plus, X, FileText, AlertCircle } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { createProjectDocTemplate } from "@/lib/project-doc-templates/service"; +import { ProjectSelector } from "@/components/ProjectSelector"; +import type { TemplateVariable } from "@/db/schema/project-doc-templates"; +import type { Project } from "@/lib/rfqs/service"; + +// 기본 변수들 (읽기 전용) +const DEFAULT_VARIABLES_DISPLAY: TemplateVariable[] = [ + { name: "document_number", displayName: "문서번호", type: "text", required: true, description: "문서 고유 번호" }, + { name: "project_code", displayName: "프로젝트 코드", type: "text", required: true, description: "프로젝트 식별 코드" }, + { name: "project_name", displayName: "프로젝트명", type: "text", required: true, description: "프로젝트 이름" }, +]; + +const templateFormSchema = z.object({ + templateName: z.string().min(1, "템플릿 이름을 입력해주세요."), + templateCode: z.string().optional(), + description: z.string().optional(), + projectId: z.number({ + required_error: "프로젝트를 선택해주세요.", + }), + customVariables: z.array(z.object({ + name: z.string().min(1, "변수명을 입력해주세요."), + displayName: z.string().min(1, "표시명을 입력해주세요."), + type: z.enum(["text", "number", "date", "select"]), + required: z.boolean(), + defaultValue: z.string().optional(), + description: z.string().optional(), + })).default([]), + file: z.instanceof(File, { + message: "파일을 업로드해주세요.", + }), +}) +.refine((data) => { + if (data.file && data.file.size > 100 * 1024 * 1024) return false; + return true; +}, { + message: "파일 크기는 100MB 이하여야 합니다.", + path: ["file"], +}) +.refine((data) => { + if (data.file) { + const validTypes = [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ]; + return validTypes.includes(data.file.type); + } + return true; +}, { + message: "워드 파일(.doc, .docx)만 업로드 가능합니다.", + path: ["file"], +}); + +type TemplateFormValues = z.infer; + +export function AddProjectDocTemplateDialog() { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const [selectedFile, setSelectedFile] = React.useState(null); + const [uploadProgress, setUploadProgress] = React.useState(0); + const [showProgress, setShowProgress] = React.useState(false); + const [selectedProject, setSelectedProject] = React.useState(null); + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(templateFormSchema), + defaultValues: { + templateName: "", + templateCode: "", + description: "", + customVariables: [], + }, + mode: "onChange", + }); + + // 프로젝트 선택 시 처리 + const handleProjectSelect = (project: Project) => { + setSelectedProject(project); + form.setValue("projectId", project.id); + // 템플릿 이름 자동 설정 (원하면) + if (!form.getValues("templateName")) { + form.setValue("templateName", `${project.projectCode} 벤더문서 커버 템플릿`); + } + }; + + const handleFileChange = (files: File[]) => { + if (files.length > 0) { + const file = files[0]; + setSelectedFile(file); + form.setValue("file", file); + } + }; + + // 사용자 정의 변수 추가 + const addCustomVariable = () => { + const currentVars = form.getValues("customVariables"); + form.setValue("customVariables", [ + ...currentVars, + { + name: "", + displayName: "", + type: "text", + required: false, + defaultValue: "", + description: "", + }, + ]); + }; + + // 사용자 정의 변수 제거 + const removeCustomVariable = (index: number) => { + const currentVars = form.getValues("customVariables"); + form.setValue("customVariables", currentVars.filter((_, i) => i !== index)); + }; + + // 청크 업로드 + const CHUNK_SIZE = 1 * 1024 * 1024; + + const uploadFileInChunks = async (file: File, fileId: string) => { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + setShowProgress(true); + setUploadProgress(0); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('chunk', chunk); + formData.append('filename', file.name); + formData.append('chunkIndex', chunkIndex.toString()); + formData.append('totalChunks', totalChunks.toString()); + formData.append('fileId', fileId); + + const response = await fetch('/api/upload/project-doc-template/chunk', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`청크 업로드 실패: ${response.statusText}`); + } + + const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100); + setUploadProgress(progress); + + const result = await response.json(); + if (chunkIndex === totalChunks - 1) { + return result; + } + } + }; + + async function onSubmit(formData: TemplateFormValues) { + setIsLoading(true); + try { + // 파일 업로드 + const fileId = uuidv4(); + const uploadResult = await uploadFileInChunks(formData.file, fileId); + + if (!uploadResult?.success) { + throw new Error("파일 업로드에 실패했습니다."); + } + + // 템플릿 생성 (고정값들 적용) + const result = await createProjectDocTemplate({ + templateName: formData.templateName, + templateCode: formData.templateCode, + description: formData.description, + projectId: formData.projectId, + templateType: "PROJECT", // 고정 + documentType: "VENDOR_DOC_COVER", // 벤더문서 커버로 고정 + filePath: uploadResult.filePath, + fileName: uploadResult.fileName, + fileSize: formData.file.size, + mimeType: formData.file.type, + variables: formData.customVariables, + isPublic: false, // 고정 + requiresApproval: false, // 고정 + }); + + if (!result.success) { + throw new Error(result.error || "템플릿 생성에 실패했습니다."); + } + + toast.success("템플릿이 성공적으로 추가되었습니다."); + form.reset(); + setSelectedFile(null); + setSelectedProject(null); + setOpen(false); + setShowProgress(false); + router.refresh(); + } catch (error) { + console.error("Submit error:", error); + toast.error(error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + } + + const customVariables = form.watch("customVariables"); + + // 다이얼로그 닫을 때 폼 초기화 + React.useEffect(() => { + if (!open) { + form.reset(); + setSelectedFile(null); + setSelectedProject(null); + setShowProgress(false); + setUploadProgress(0); + } + }, [open, form]); + + return ( + + + + + + {/* 헤더 - 고정 */} + + 프로젝트 벤더문서 커버 템플릿 추가 + + 프로젝트별 벤더문서 커버 템플릿을 등록합니다. 기본 변수(document_number, project_code, project_name)는 자동으로 포함됩니다. + + + + {/* 본문 - 스크롤 영역 */} +
+
+ + {/* 프로젝트 선택 및 기본 정보 */} + + + 기본 정보 + + + {/* 프로젝트 선택 - 필수 */} + ( + + + 프로젝트 * + + + + + + 템플릿을 적용할 프로젝트를 선택하세요. + + + + )} + /> + + {selectedProject && ( +
+

+ 선택된 프로젝트: {selectedProject.projectCode} - {selectedProject.projectName} +

+
+ )} + +
+ ( + + + 템플릿 이름 * + + + + + + + )} + /> + + ( + + 템플릿 코드 + + + + 비워두면 자동으로 생성됩니다. + + + )} + /> +
+ + ( + + 설명 + +