summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-08-11 09:34:40 +0000
committerjoonhoekim <26rote@gmail.com>2025-08-11 09:34:40 +0000
commitbcd462d6e60871b86008e072f4b914138fc5c328 (patch)
treec22876fd6c6e7e48254587848b9dff50cdb8b032
parentcbb4c7fe0b94459162ad5e998bc05cd293e0ff96 (diff)
(김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트), command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정
-rw-r--r--app/[lng]/admin/approval-test/page.tsx8
-rw-r--r--app/[lng]/evcp/(evcp)/approval/line/page.tsx68
-rw-r--r--app/[lng]/evcp/(evcp)/approval/template/[id]/config.ts33
-rw-r--r--app/[lng]/evcp/(evcp)/approval/template/[id]/page.tsx58
-rw-r--r--app/[lng]/evcp/(evcp)/approval/template/page.tsx68
-rw-r--r--app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts42
-rw-r--r--components/common/organization/organization-manager-selector.tsx338
-rw-r--r--components/knox/approval/ApprovalLineSelector.tsx444
-rw-r--r--components/knox/approval/ApprovalSubmit.tsx2230
-rw-r--r--components/layout/command-menu.tsx26
-rw-r--r--components/rich-text-editor/BlockquoteButton.tsx38
-rw-r--r--components/rich-text-editor/BulletListButton.tsx46
-rw-r--r--components/rich-text-editor/HistoryMenu.tsx43
-rw-r--r--components/rich-text-editor/InlineStyleMenu.tsx67
-rw-r--r--components/rich-text-editor/OrderedListButton.tsx38
-rw-r--r--components/rich-text-editor/RichTextEditor.tsx1050
-rw-r--r--components/rich-text-editor/StyleMenu.tsx65
-rw-r--r--components/rich-text-editor/TextAlignMenu.tsx46
-rw-r--r--components/rich-text-editor/Toolbar.tsx350
-rw-r--r--components/rich-text-editor/extensions/font-size.ts31
-rw-r--r--config/menuConfig.ts13
-rw-r--r--db/schema/knox/approvals.ts98
-rw-r--r--db/schema/procurementRFQ.ts28
-rw-r--r--db/schema/projects.ts2
-rw-r--r--i18n/locales/en/menu.json4
-rw-r--r--i18n/locales/ko/menu.json3
-rw-r--r--lib/approval-line/service.ts341
-rw-r--r--lib/approval-line/table/approval-line-table-columns.tsx197
-rw-r--r--lib/approval-line/table/approval-line-table-toolbar-actions.tsx73
-rw-r--r--lib/approval-line/table/approval-line-table.tsx130
-rw-r--r--lib/approval-line/table/create-approval-line-sheet.tsx224
-rw-r--r--lib/approval-line/table/delete-approval-line-dialog.tsx168
-rw-r--r--lib/approval-line/table/duplicate-approval-line-sheet.tsx206
-rw-r--r--lib/approval-line/table/update-approval-line-sheet.tsx264
-rw-r--r--lib/approval-line/utils/format.ts53
-rw-r--r--lib/approval-line/validations.ts24
-rw-r--r--lib/approval-template/editor/approval-template-editor.tsx257
-rw-r--r--lib/approval-template/service.ts330
-rw-r--r--lib/approval-template/table/approval-template-table-columns.tsx165
-rw-r--r--lib/approval-template/table/approval-template-table-toolbar-actions.tsx114
-rw-r--r--lib/approval-template/table/approval-template-table.tsx135
-rw-r--r--lib/approval-template/table/create-approval-template-sheet.tsx174
-rw-r--r--lib/approval-template/table/delete-approval-template-dialog.tsx123
-rw-r--r--lib/approval-template/table/duplicate-approval-template-sheet.tsx143
-rw-r--r--lib/approval-template/table/update-approval-template-sheet.tsx23
-rw-r--r--lib/approval-template/validations.ts27
-rw-r--r--lib/knox-api/approval/service.ts30
-rw-r--r--lib/knox-api/organization-service.ts95
-rw-r--r--lib/nonsap-sync/procurement-sync-service.ts14
-rw-r--r--lib/pdftron/serverSDK/createBasicContractPdf.ts9
-rw-r--r--lib/soap/ecc-mapper.ts429
-rw-r--r--lib/users/knox-service.ts37
-rw-r--r--package-lock.json27
-rw-r--r--package.json4
-rw-r--r--public/wsdl/IF_ECC_EVCP_PCR.wsdl41
-rw-r--r--public/wsdl/IF_ECC_EVCP_PO_INFORMATION.wsdl183
-rw-r--r--public/wsdl/IF_ECC_EVCP_PR_INFORMATION.wsdl10
-rw-r--r--public/wsdl/IF_ECC_EVCP_REJECT_FOR_REVISED_PR.wsdl22
-rw-r--r--public/wsdl/IF_EVCP_ECC_PCR_CONFIRM.wsdl1
-rw-r--r--public/wsdl/품질/IF_ECC_EVCP_PR_INFORMATION.wsdl10
60 files changed, 7196 insertions, 2124 deletions
diff --git a/app/[lng]/admin/approval-test/page.tsx b/app/[lng]/admin/approval-test/page.tsx
index ab5654f3..439c7ba8 100644
--- a/app/[lng]/admin/approval-test/page.tsx
+++ b/app/[lng]/admin/approval-test/page.tsx
@@ -1,12 +1,17 @@
import { Metadata } from 'next';
import ApprovalManager from '@/components/knox/approval/ApprovalManager';
+import { findUserByEmail } from '@/lib/users/service';
+import { getServerSession } from 'next-auth/next';
export const metadata: Metadata = {
title: 'Knox 결재 시스템 | Admin',
description: 'Knox API를 사용한 결재 시스템',
};
-export default function ApprovalTestPage() {
+export default async function ApprovalTestPage() {
+ const session = await getServerSession();
+ const currentUser = await findUserByEmail(session?.user?.email ?? '');
+
return (
<div className="container mx-auto py-8">
<div className="space-y-6">
@@ -21,6 +26,7 @@ export default function ApprovalTestPage() {
{/* 결재 관리자 컴포넌트 */}
<ApprovalManager
defaultTab="submit"
+ currentUser={currentUser}
/>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/approval/line/page.tsx b/app/[lng]/evcp/(evcp)/approval/line/page.tsx
new file mode 100644
index 00000000..435d1071
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/approval/line/page.tsx
@@ -0,0 +1,68 @@
+import * as React from 'react';
+import { type Metadata } from 'next';
+import { Shell } from '@/components/shell';
+import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton';
+import { type SearchParams } from '@/types/table';
+import { getValidFilters } from '@/lib/data-table';
+
+import { getApprovalLineList } from '@/lib/approval-line/service';
+import { SearchParamsApprovalLineCache } from '@/lib/approval-line/validations';
+import { ApprovalLineTable } from '@/lib/approval-line/table/approval-line-table';
+
+export const metadata: Metadata = {
+ title: '결재선 관리',
+ description: '결재용 결재선을 관리합니다.',
+};
+
+interface PageProps {
+ searchParams: SearchParams;
+}
+
+export default async function ApprovalLinePage({ searchParams }: PageProps) {
+ const search = SearchParamsApprovalLineCache.parse(searchParams);
+ // getValidFilters 반환값이 undefined 인 경우 폴백
+ const validFilters = getValidFilters(search.filters) ?? [];
+
+ const promises = Promise.all([
+ getApprovalLineList({
+ ...search,
+ filters: validFilters,
+ }),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">결재선 관리</h2>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 테이블 */}
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={[
+ '10rem',
+ '20rem',
+ '30rem',
+ '12rem',
+ '12rem',
+ '8rem',
+ ]}
+ shrinkZero
+ />
+ }
+ >
+ <ApprovalLineTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+}
diff --git a/app/[lng]/evcp/(evcp)/approval/template/[id]/config.ts b/app/[lng]/evcp/(evcp)/approval/template/[id]/config.ts
new file mode 100644
index 00000000..1ad5b2e2
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/approval/template/[id]/config.ts
@@ -0,0 +1,33 @@
+// 결재 템플릿에서 사용할 변수(placeholder) 목록
+// DB를 거치지 않고 정적 파일에 정의하여 테스트/배포 리스크를 최소화합니다.
+// ApprovalTemplateEditor 컴포넌트는 `variableName` 값을 사용해 {{변수명}} 형식으로 본문에 삽입합니다.
+
+export const variables = [
+ {
+ variableName: "수신자 배열", // 예: ["홍길동", "김철수", ...]
+ variableType: "array",
+ description: "결재 수신자 목록 (이름/사번 등)",
+ },
+ {
+ variableName: "송신자 배열", // 예: 상신자 정보 배열
+ variableType: "array",
+ description: "결재 송신자(상신자) 목록",
+ },
+ {
+ variableName: "견적 RFQ 요약", // 예: RFQ 견적 요약 HTML 테이블
+ variableType: "html",
+ description: "견적 RFQ 요약표",
+ },
+ {
+ variableName: "RFQ(PR) 요약", // 예: 구매요청(PR) 요약 HTML 테이블
+ variableType: "html",
+ description: "RFQ(PR) 요약표",
+ },
+ {
+ variableName: "첨부문서 리스트 요약", // 예: 첨부 파일 리스트 HTML
+ variableType: "html",
+ description: "첨부문서 리스트 요약",
+ },
+] as const;
+
+
diff --git a/app/[lng]/evcp/(evcp)/approval/template/[id]/page.tsx b/app/[lng]/evcp/(evcp)/approval/template/[id]/page.tsx
new file mode 100644
index 00000000..136b09eb
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/approval/template/[id]/page.tsx
@@ -0,0 +1,58 @@
+import * as React from "react"
+import { type Metadata } from "next"
+import { notFound } from "next/navigation"
+
+import { getApprovalTemplate } from "@/lib/approval-template/service"
+import { getApprovalLineOptions, getApprovalLineCategories } from "@/lib/approval-line/service"
+import { ApprovalTemplateEditor } from "@/lib/approval-template/editor/approval-template-editor"
+import { variables as configVariables } from "./config"
+
+interface ApprovalTemplateDetailPageProps {
+ params: Promise<{
+ id: string
+ }>
+}
+
+export async function generateMetadata({ params }: ApprovalTemplateDetailPageProps): Promise<Metadata> {
+ const { id } = await params
+ const template = await getApprovalTemplate(id)
+
+ if (!template) {
+ return {
+ title: "템플릿을 찾을 수 없음",
+ }
+ }
+
+ return {
+ title: `${template.name} - 템플릿 편집`,
+ description: template.description || `${template.name} 템플릿을 편집합니다.`,
+ }
+}
+
+export default async function ApprovalTemplateDetailPage({ params }: ApprovalTemplateDetailPageProps) {
+ const { id } = await params
+ const [template, approvalLineOptions, approvalLineCategories] = await Promise.all([
+ getApprovalTemplate(id),
+ getApprovalLineOptions(),
+ getApprovalLineCategories(),
+ ])
+
+ if (!template) {
+ notFound()
+ }
+
+ return (
+ <div className="flex flex-1 flex-col">
+ {template && (
+ <ApprovalTemplateEditor
+ templateId={id}
+ initialTemplate={template}
+ staticVariables={configVariables as unknown as Array<{ variableName: string }>}
+ approvalLineOptions={approvalLineOptions}
+ approvalLineCategories={approvalLineCategories}
+ />
+ )}
+ </div>
+ )
+}
+
diff --git a/app/[lng]/evcp/(evcp)/approval/template/page.tsx b/app/[lng]/evcp/(evcp)/approval/template/page.tsx
new file mode 100644
index 00000000..f475099c
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/approval/template/page.tsx
@@ -0,0 +1,68 @@
+import * as React from 'react';
+import { type Metadata } from 'next';
+import { Shell } from '@/components/shell';
+import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton';
+import { type SearchParams } from '@/types/table';
+import { getValidFilters } from '@/lib/data-table';
+
+import { getApprovalTemplateList } from '@/lib/approval-template/service';
+import { SearchParamsApprovalTemplateCache } from '@/lib/approval-template/validations';
+import { ApprovalTemplateTable } from '@/lib/approval-template/table/approval-template-table';
+
+export const metadata: Metadata = {
+ title: '결재 템플릿 관리',
+ description: '결재용 템플릿을 관리합니다.',
+};
+
+interface PageProps {
+ searchParams: SearchParams;
+}
+
+export default async function ApprovalTemplatePage({ searchParams }: PageProps) {
+ const search = SearchParamsApprovalTemplateCache.parse(searchParams);
+ // getValidFilters 반환값이 undefined 인 경우 폴백
+ const validFilters = getValidFilters(search.filters) ?? [];
+
+ const promises = Promise.all([
+ getApprovalTemplateList({
+ ...search,
+ filters: validFilters,
+ }),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">결재 템플릿 관리</h2>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 테이블 */}
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={[
+ '10rem',
+ '40rem',
+ '12rem',
+ '12rem',
+ '8rem',
+ '8rem',
+ ]}
+ shrinkZero
+ />
+ }
+ >
+ <ApprovalTemplateTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts
index fdf0c8d4..4ae1bbda 100644
--- a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts
+++ b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts
@@ -11,8 +11,6 @@ import {
extractRequestData,
convertXMLToDBData,
processNestedArray,
- createErrorResponse,
- createSuccessResponse,
createSoapResponse,
withSoapLogging,
} from '@/lib/soap/utils';
@@ -20,6 +18,9 @@ import {
bulkUpsert,
bulkReplaceSubTableData
} from "@/lib/soap/batch-utils";
+import {
+ mapAndSaveECCRfqData
+} from "@/lib/soap/ecc-mapper";
// 스키마에서 타입 추론
@@ -90,10 +91,43 @@ export async function POST(request: NextRequest) {
}
}
- // 5) 데이터베이스 저장
+ // 5) 원본 ECC 데이터 저장 (기존 로직 유지)
await saveToDatabase(processedData);
- console.log(`🎉 처리 완료: ${processedData.length}개 PR 데이터`);
+ // 6) ZBSART에 따라 비즈니스 테이블 분기 처리
+ const anHeaders: BidHeaderData[] = [];
+ const abHeaders: BidHeaderData[] = [];
+ const anItems: BidItemData[] = [];
+ const abItems: BidItemData[] = [];
+
+ // ZBSART에 따라 데이터 분류
+ for (const prData of processedData) {
+ if (prData.bidHeader.ZBSART === 'AN') {
+ anHeaders.push(prData.bidHeader);
+ anItems.push(...prData.bidItems);
+ } else if (prData.bidHeader.ZBSART === 'AB') {
+ abHeaders.push(prData.bidHeader);
+ abItems.push(...prData.bidItems);
+ }
+ }
+
+ // AN (RFQ) 데이터 처리 - procurementRfqs 테이블
+ let rfqMappingResult = null;
+ if (anHeaders.length > 0) {
+ rfqMappingResult = await mapAndSaveECCRfqData(anHeaders, anItems);
+ if (!rfqMappingResult.success) {
+ throw new Error(`RFQ 비즈니스 테이블 매핑 실패: ${rfqMappingResult.message}`);
+ }
+ }
+
+ // AB (Bidding) 데이터 처리 - TODO
+ if (abHeaders.length > 0) {
+ console.log(`⚠️ TODO: Bidding 데이터 처리 필요 - ${abHeaders.length}개 헤더, ${abItems.length}개 아이템`);
+ // TODO: mapAndSaveECCBiddingData 함수 구현 필요
+ // const biddingMappingResult = await mapAndSaveECCBiddingData(abHeaders, abItems);
+ }
+
+ console.log(`🎉 처리 완료: ${processedData.length}개 PR 데이터, ${rfqMappingResult?.processedCount || 0}개 RFQ 매핑, ${abHeaders.length}개 Bidding (TODO)`);
// 6) 성공 응답 반환
return createSoapResponse('http://60.101.108.100/', {
diff --git a/components/common/organization/organization-manager-selector.tsx b/components/common/organization/organization-manager-selector.tsx
new file mode 100644
index 00000000..c715b3a1
--- /dev/null
+++ b/components/common/organization/organization-manager-selector.tsx
@@ -0,0 +1,338 @@
+"use client"
+
+import * as React from "react"
+import { Search, X, Building2, ChevronLeft, ChevronRight } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { useDebounce } from "@/hooks/use-debounce"
+import { cn } from "@/lib/utils"
+import { Skeleton } from "@/components/ui/skeleton"
+import { searchOrganizationsForManager } from "@/lib/knox-api/organization-service"
+
+// 조직 관리자 타입 정의
+export interface OrganizationManagerItem {
+ id: string
+ departmentCode: string
+ departmentName: string
+ managerId: string
+ managerName: string
+ managerTitle: string
+ companyCode: string
+ companyName: string
+}
+
+// 페이지네이션 정보 타입
+interface PaginationInfo {
+ page: number
+ perPage: number
+ total: number
+ pageCount: number
+ hasNextPage: boolean
+ hasPrevPage: boolean
+}
+
+export interface OrganizationManagerSelectorProps {
+ /** 선택된 조직 관리자들 */
+ selectedManagers?: OrganizationManagerItem[]
+ /** 조직 관리자 선택 변경 콜백 */
+ onManagersChange?: (managers: OrganizationManagerItem[]) => void
+ /** 단일 선택 모드 여부 */
+ singleSelect?: boolean
+ /** placeholder 텍스트 */
+ placeholder?: string
+ /** 입력 없이 focus 시 표시할 placeholder */
+ noValuePlaceHolder?: string
+ /** 비활성화 여부 */
+ disabled?: boolean
+ /** 최대 선택 가능 조직 관리자 수 */
+ maxSelections?: number
+ /** 컴포넌트 클래스명 */
+ className?: string
+ /** 선택 후 팝오버 닫기 여부 */
+ closeOnSelect?: boolean
+}
+
+export function OrganizationManagerSelector({
+ selectedManagers = [],
+ onManagersChange,
+ singleSelect = false,
+ placeholder = "조직 관리자를 검색하세요...",
+ noValuePlaceHolder = "조직명 또는 관리자명으로 검색하세요",
+ disabled = false,
+ maxSelections,
+ className,
+ closeOnSelect = true
+}: OrganizationManagerSelectorProps) {
+ const [searchQuery, setSearchQuery] = React.useState("")
+ const [isSearching, setIsSearching] = React.useState(false)
+ const [searchResults, setSearchResults] = React.useState<OrganizationManagerItem[]>([])
+ const [isPopoverOpen, setIsPopoverOpen] = React.useState(false)
+ const [currentPage, setCurrentPage] = React.useState(1)
+ const [pagination, setPagination] = React.useState<PaginationInfo>({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ })
+ const [searchError, setSearchError] = React.useState<string | null>(null)
+
+ const inputRef = React.useRef<HTMLInputElement>(null)
+
+ // Debounce 적용된 검색어
+ const debouncedSearchQuery = useDebounce(searchQuery, 300)
+
+ // 검색 실행
+ const performSearch = React.useCallback(async (query: string, page: number = 1) => {
+ if (!query.trim()) {
+ setSearchResults([])
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ })
+ return
+ }
+
+ setIsSearching(true)
+ setSearchError(null)
+
+ try {
+ const result = await searchOrganizationsForManager({
+ search: query,
+ page,
+ perPage: 10,
+ })
+
+ setSearchResults(result.data)
+ setPagination({
+ page: result.pageCount,
+ perPage: 10,
+ total: result.total,
+ pageCount: result.pageCount,
+ hasNextPage: page < result.pageCount,
+ hasPrevPage: page > 1,
+ })
+ } catch (error) {
+ setSearchError("검색 중 오류가 발생했습니다.")
+ setSearchResults([])
+ } finally {
+ setIsSearching(false)
+ }
+ }, [])
+
+ // 검색어 변경 시 검색 실행
+ React.useEffect(() => {
+ performSearch(debouncedSearchQuery, 1)
+ setCurrentPage(1)
+ }, [debouncedSearchQuery, performSearch])
+
+ // 페이지 변경 시 검색 실행
+ const handlePageChange = React.useCallback((newPage: number) => {
+ setCurrentPage(newPage)
+ performSearch(debouncedSearchQuery, newPage)
+ }, [debouncedSearchQuery, performSearch])
+
+ // 선택된 관리자 제거
+ const removeManager = React.useCallback((managerId: string) => {
+ const updated = selectedManagers.filter(m => m.id !== managerId)
+ onManagersChange?.(updated)
+ }, [selectedManagers, onManagersChange])
+
+ // 관리자 선택
+ const selectManager = React.useCallback((manager: OrganizationManagerItem) => {
+ if (singleSelect) {
+ onManagersChange?.([manager])
+ if (closeOnSelect) {
+ setIsPopoverOpen(false)
+ }
+ return
+ }
+
+ // 최대 선택 수 체크
+ if (maxSelections && selectedManagers.length >= maxSelections) {
+ return
+ }
+
+ // 이미 선택된 관리자인지 확인
+ const isAlreadySelected = selectedManagers.some(m => m.id === manager.id)
+ if (isAlreadySelected) {
+ return
+ }
+
+ const updated = [...selectedManagers, manager]
+ onManagersChange?.(updated)
+
+ if (closeOnSelect) {
+ setIsPopoverOpen(false)
+ }
+ }, [selectedManagers, onManagersChange, singleSelect, maxSelections, closeOnSelect])
+
+ // 전체 선택 해제
+ const clearAll = React.useCallback(() => {
+ onManagersChange?.([])
+ }, [onManagersChange])
+
+ return (
+ <div className={cn("w-full", className)}>
+ {/* 선택된 관리자들 표시 */}
+ {selectedManagers.length > 0 && (
+ <div className="mb-3 flex flex-wrap gap-2">
+ {selectedManagers.map((manager) => (
+ <Badge
+ key={manager.id}
+ variant="secondary"
+ className="flex items-center gap-1"
+ >
+ <Building2 className="w-3 h-3" />
+ <span className="text-xs">
+ {manager.departmentName} - {manager.managerName}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-0 ml-1 hover:bg-transparent"
+ onClick={() => removeManager(manager.id)}
+ >
+ <X className="w-3 h-3" />
+ </Button>
+ </Badge>
+ ))}
+ {selectedManagers.length > 1 && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={clearAll}
+ className="h-6 px-2 text-xs"
+ >
+ 전체 해제
+ </Button>
+ )}
+ </div>
+ )}
+
+ {/* 검색 입력 */}
+ <div className="relative">
+ <Input
+ ref={inputRef}
+ placeholder={selectedManagers.length === 0 ? placeholder : noValuePlaceHolder}
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ onFocus={() => setIsPopoverOpen(true)}
+ disabled={disabled}
+ className="w-full"
+ />
+
+ {/* 검색 결과 팝오버 */}
+ {isPopoverOpen && (
+ <div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-80 overflow-hidden">
+ {/* 검색 중 표시 */}
+ {isSearching && (
+ <div className="p-4 space-y-2">
+ {Array.from({ length: 3 }).map((_, i) => (
+ <div key={i} className="flex items-center space-x-3">
+ <Skeleton className="h-4 w-4" />
+ <div className="space-y-2 flex-1">
+ <Skeleton className="h-4 w-3/4" />
+ <Skeleton className="h-3 w-1/2" />
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 검색 결과 */}
+ {!isSearching && searchResults.length > 0 && (
+ <div className="max-h-60 overflow-y-auto">
+ {searchResults.map((manager) => {
+ const isSelected = selectedManagers.some(m => m.id === manager.id)
+ return (
+ <div
+ key={manager.id}
+ className={cn(
+ "p-3 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0",
+ isSelected && "bg-blue-50"
+ )}
+ onClick={() => selectManager(manager)}
+ >
+ <div className="flex items-center justify-between">
+ <div className="flex-1">
+ <div className="font-medium text-sm">
+ {manager.departmentName}
+ </div>
+ <div className="text-xs text-gray-500">
+ {manager.managerName} ({manager.managerTitle})
+ </div>
+ <div className="text-xs text-gray-400">
+ {manager.companyName}
+ </div>
+ </div>
+ {isSelected && (
+ <Badge variant="secondary" className="text-xs">
+ 선택됨
+ </Badge>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ {/* 검색 결과 없음 */}
+ {!isSearching && searchQuery && searchResults.length === 0 && !searchError && (
+ <div className="p-4 text-center text-gray-500">
+ 검색 결과가 없습니다.
+ </div>
+ )}
+
+ {/* 오류 메시지 */}
+ {searchError && (
+ <div className="p-4 text-center text-red-500">
+ {searchError}
+ </div>
+ )}
+
+ {/* 페이지네이션 */}
+ {!isSearching && searchResults.length > 0 && (
+ <div className="flex items-center justify-between p-3 border-t border-gray-200">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage - 1)}
+ disabled={!pagination.hasPrevPage}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+ <span className="text-sm text-gray-500">
+ {currentPage} / {pagination.pageCount}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage + 1)}
+ disabled={!pagination.hasNextPage}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* 팝오버 외부 클릭 시 닫기 */}
+ {isPopoverOpen && (
+ <div
+ className="fixed inset-0 z-40"
+ onClick={() => setIsPopoverOpen(false)}
+ />
+ )}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/knox/approval/ApprovalLineSelector.tsx b/components/knox/approval/ApprovalLineSelector.tsx
new file mode 100644
index 00000000..bbe6bd7f
--- /dev/null
+++ b/components/knox/approval/ApprovalLineSelector.tsx
@@ -0,0 +1,444 @@
+"use client";
+
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Separator } from "@/components/ui/separator";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { toast } from "sonner";
+import { Trash2, GripVertical } from "lucide-react";
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ restrictToVerticalAxis,
+ restrictToParentElement,
+} from "@dnd-kit/modifiers";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+ useSortable,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import {
+ UserSelector,
+ type UserSelectItem,
+} from "@/components/common/user/user-selector";
+
+// ----- Types -----
+export type ApprovalRole = "0" | "1" | "2" | "3" | "4" | "7" | "9";
+
+export interface ApprovalLineItem {
+ id: string;
+ epId?: string;
+ userId?: string;
+ emailAddress?: string;
+ name?: string;
+ deptName?: string;
+ role: ApprovalRole;
+ seq: string; // 0-based; 동일 seq는 병렬 그룹 의미
+ opinion?: string;
+}
+
+export interface ApprovalLineSelectorProps {
+ value: ApprovalLineItem[];
+ onChange: (next: ApprovalLineItem[]) => void;
+ placeholder?: string;
+ maxSelections?: number;
+ domainFilter?: any; // 프로젝트 전역 타입에 맞춰 느슨히 둠
+ className?: string;
+}
+
+// 역할 텍스트 매핑
+const getRoleText = (role: string) => {
+ const map: Record<string, string> = {
+ "0": "기안",
+ "1": "결재",
+ "2": "합의",
+ "3": "후결",
+ "4": "병렬합의",
+ "7": "병렬결재",
+ "9": "통보",
+ };
+ return map[role] || role;
+};
+
+// 고유 ID 생성
+const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
+
+// Sortable Group Card (seq 단위)
+interface SortableApprovalGroupProps {
+ group: ApprovalLineItem[];
+ index: number;
+ onRemoveGroup: () => void;
+ canRemove: boolean;
+ selected: boolean;
+ onSelect: () => void;
+ onChangeRole: (role: ApprovalRole) => void;
+}
+
+function SortableApprovalGroup({
+ group,
+ index,
+ onRemoveGroup,
+ canRemove,
+ selected,
+ onSelect,
+ onChangeRole,
+}: SortableApprovalGroupProps) {
+ const seq = group[0].seq;
+ const role = group[0].role;
+
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
+ useSortable({ id: group[0].id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ } as React.CSSProperties;
+
+ const isParallel = role === "7" || role === "4";
+
+ return (
+ <div
+ ref={setNodeRef}
+ style={style}
+ className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`}
+ >
+ {/* Drag handle */}
+ <div
+ {...attributes}
+ {...listeners}
+ className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
+ >
+ <GripVertical className="w-5 h-5" />
+ </div>
+
+ {/* Group select (skip for drafter) */}
+ {index !== 0 ? (
+ <Checkbox checked={selected} onCheckedChange={onSelect} onClick={(e) => e.stopPropagation()} />
+ ) : (
+ <div className="w-4 h-4" />
+ )}
+
+ {/* seq index */}
+ <Badge variant="outline">{parseInt(seq) + 1}</Badge>
+
+ {/* Group details */}
+ <div className="flex-1 grid grid-cols-3 gap-3">
+ {/* Users in group */}
+ <div className="flex flex-col justify-center gap-1">
+ {group.map((u) => (
+ <div key={u.id} className="text-sm">
+ {u.name || "Knox 이름 없음"}
+ {u.deptName ? ` / ${u.deptName}` : ""}
+ </div>
+ ))}
+ </div>
+
+ {/* Role UI */}
+ <div className="flex items-center">
+ {seq === "0" ? (
+ <Badge variant="secondary" className="w-full justify-center">기안</Badge>
+ ) : isParallel ? (
+ <Badge variant="secondary" className="w-full justify-center">{getRoleText(role)}</Badge>
+ ) : (
+ <ToggleGroup
+ type="single"
+ value={role}
+ onValueChange={(val) => val && onChangeRole(val as ApprovalRole)}
+ >
+ <ToggleGroupItem value="1">결재</ToggleGroupItem>
+ <ToggleGroupItem value="2">합의</ToggleGroupItem>
+ <ToggleGroupItem value="9">통보</ToggleGroupItem>
+ </ToggleGroup>
+ )}
+ </div>
+
+ {/* Delete */}
+ <div className="flex items-center justify-end">
+ {canRemove && (
+ <Button type="button" variant="ghost" size="sm" onClick={onRemoveGroup}>
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+export function ApprovalLineSelector({
+ value,
+ onChange,
+ placeholder = "결재자를 검색하세요...",
+ maxSelections = 10,
+ domainFilter,
+ className,
+}: ApprovalLineSelectorProps) {
+ const aplns = value;
+ const [selectedSeqs, setSelectedSeqs] = React.useState<string[]>([]);
+
+ const toggleSelectGroup = (seq: string) => {
+ setSelectedSeqs((prev) => (prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq]));
+ };
+ const clearSelection = () => setSelectedSeqs([]);
+
+ // drag sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ );
+
+ // Utilities
+ const reorderBySeq = (list: ApprovalLineItem[]): ApprovalLineItem[] => {
+ const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq));
+ const seqMap = new Map<string, string>();
+ let nextSeq = 0;
+ return sorted.map((apln) => {
+ if (!seqMap.has(apln.seq)) {
+ seqMap.set(apln.seq, nextSeq.toString());
+ nextSeq += 1;
+ }
+ return { ...apln, seq: seqMap.get(apln.seq)! };
+ });
+ };
+
+ const addApprovalUsers = (users: UserSelectItem[]) => {
+ const newAplns = [...aplns];
+ users.forEach((user) => {
+ const exists = newAplns.findIndex((apln) => apln.userId === user.id.toString());
+ if (exists === -1) {
+ // 현재 존재하는 seq 중 최댓값 + 1을 다음 seq로 사용 (0은 상신자)
+ const uniqueSeqs = Array.from(new Set(newAplns.map((a) => parseInt(a.seq))));
+ const maxSeq = uniqueSeqs.length ? Math.max(...uniqueSeqs) : -1;
+ const newSeq = (Math.max(1, maxSeq + 1)).toString();
+ newAplns.push({
+ id: generateUniqueId(),
+ epId: (user as any).epId,
+ userId: user.id.toString(),
+ emailAddress: user.email,
+ name: user.name,
+ deptName: (user as any).deptName ?? undefined,
+ role: "1",
+ seq: newSeq,
+ opinion: "",
+ });
+ }
+ });
+ onChange(newAplns);
+ };
+
+ const removeApprovalGroup = (seq: string) => {
+ if (seq === "0") return; // 상신자 삭제 불가
+ const remaining = aplns.filter((a) => a.seq !== seq);
+ onChange(reorderBySeq(remaining));
+ };
+
+ const applyParallel = () => {
+ if (selectedSeqs.length < 2) {
+ toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다.");
+ return;
+ }
+ const current = aplns;
+ const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq));
+ const roles = Array.from(new Set(selectedAplns.map((a) => a.role)));
+ if (roles.length !== 1) {
+ toast.error("선택된 항목의 역할이 동일해야 합니다.");
+ return;
+ }
+ const role = roles[0];
+ let newRole: ApprovalRole;
+ if (role === "1") newRole = "7";
+ else if (role === "2") newRole = "4";
+ else if (role === "9") newRole = "9";
+ else {
+ toast.error("결재, 합의 또는 통보만 병렬 지정 가능합니다.");
+ return;
+ }
+ const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq)));
+ const updated = current.map((a) =>
+ selectedSeqs.includes(a.seq) ? { ...a, role: newRole, seq: minSeq.toString() } : a,
+ );
+ onChange(reorderBySeq(updated));
+ clearSelection();
+ };
+
+ const applyAfter = () => {
+ if (selectedSeqs.length !== 1) {
+ toast.error("후결은 한 명만 지정할 수 있습니다.");
+ return;
+ }
+ const targetSeq = selectedSeqs[0];
+ const targetRole = aplns.find((a) => a.seq === targetSeq)?.role;
+ if (targetRole === "7" || targetRole === "4") {
+ toast.error("병렬 그룹은 후결로 전환할 수 없습니다.");
+ return;
+ }
+ const updated = aplns.map((a) =>
+ a.seq === targetSeq ? { ...a, role: (a.role === "3" ? "1" : "3") as ApprovalRole } : a,
+ );
+ onChange(reorderBySeq(updated));
+ clearSelection();
+ };
+
+ const ungroupParallel = () => {
+ if (selectedSeqs.length === 0) {
+ toast.error("해제할 결재선을 선택하세요.");
+ return;
+ }
+ let newSeqCounter = 1; // 0은 상신자 유지
+ const updated = aplns.map((a) => {
+ if (selectedSeqs.includes(a.seq)) {
+ let newRole: ApprovalRole = a.role;
+ if (a.role === "7") newRole = "1";
+ if (a.role === "4") newRole = "2";
+ return { ...a, role: newRole, seq: "" };
+ }
+ return { ...a };
+ });
+ const reassigned = updated
+ .sort((x, y) => parseInt(x.seq || "0") - parseInt(y.seq || "0"))
+ .map((a) => {
+ if (a.seq === "0") return a;
+ const next = { ...a, seq: newSeqCounter.toString() };
+ newSeqCounter += 1;
+ return next;
+ });
+ onChange(reassigned);
+ clearSelection();
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const activeKey = active.id as string;
+ const overKey = over.id as string;
+
+ const idToSeq = new Map<string, string>();
+ aplns.forEach((a) => idToSeq.set(a.id, a.seq));
+ const activeSeq = idToSeq.get(activeKey);
+ const overSeq = idToSeq.get(overKey);
+ if (!activeSeq || !overSeq) return;
+ if (activeSeq === "0" || overSeq === "0") return; // 상신자 이동 불가
+
+ const seqOrder = Array.from(new Set(aplns.map((a) => a.seq)));
+ const keyOrder = seqOrder.map((seq) => aplns.find((a) => a.seq === seq)!.id);
+ const oldIndex = keyOrder.indexOf(activeKey);
+ const newIndex = keyOrder.indexOf(overKey);
+ const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex);
+
+ const keyToNewSeq = new Map<string, string>();
+ newKeyOrder.forEach((k, idx) => keyToNewSeq.set(k, idx.toString()));
+
+ const updatedAplns: ApprovalLineItem[] = [];
+ newKeyOrder.forEach((k) => {
+ const oldSeq = idToSeq.get(k)!;
+ const groupItems = aplns.filter((a) => a.seq === oldSeq);
+ groupItems.forEach((item) => {
+ updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! });
+ });
+ });
+ onChange(updatedAplns);
+ };
+
+ // Render groups by seq
+ const groups = React.useMemo(() => {
+ const grouped = Object.values(
+ aplns.reduce<Record<string, ApprovalLineItem[]>>((acc, apln) => {
+ acc[apln.seq] = acc[apln.seq] ? [...acc[apln.seq], apln] : [apln];
+ return acc;
+ }, {}),
+ ).sort((a, b) => parseInt(a[0].seq) - parseInt(b[0].seq));
+ return grouped;
+ }, [aplns]);
+
+ return (
+ <div className={className}>
+ <h3 className="text-lg font-semibold">결재 경로</h3>
+
+ {/* Controls */}
+ <div className="flex justify-end gap-2 mb-2">
+ <Button type="button" variant="outline" size="sm" onClick={applyParallel}>
+ 병렬
+ </Button>
+ <Button type="button" variant="outline" size="sm" onClick={applyAfter}>
+ 후결
+ </Button>
+ <Button type="button" variant="outline" size="sm" onClick={ungroupParallel}>
+ 해제
+ </Button>
+ </div>
+
+ {/* User add */}
+ <div className="p-4 border border-dashed border-gray-300 rounded-lg">
+ <div className="mb-2">
+ <label className="text-sm font-medium text-gray-700">결재자 추가</label>
+ <p className="text-xs text-gray-500">사용자를 검색하여 결재 라인에 추가하세요</p>
+ </div>
+ <UserSelector
+ selectedUsers={[]}
+ onUsersChange={addApprovalUsers}
+ placeholder={placeholder}
+ domainFilter={domainFilter}
+ maxSelections={maxSelections}
+ />
+ </div>
+
+ {/* Groups */}
+ <div className="mt-4">
+ {aplns.length > 0 ? (
+ <DndContext
+ sensors={sensors}
+ collisionDetection={closestCenter}
+ modifiers={[restrictToVerticalAxis, restrictToParentElement]}
+ onDragEnd={handleDragEnd}
+ >
+ <SortableContext items={groups.map((g) => g[0].id)} strategy={verticalListSortingStrategy}>
+ <div className="space-y-3">
+ {groups.map((group, idx) => (
+ <SortableApprovalGroup
+ key={group[0].id}
+ group={group}
+ index={idx}
+ onRemoveGroup={() => removeApprovalGroup(group[0].seq)}
+ canRemove={idx !== 0 && aplns.length > 1}
+ selected={selectedSeqs.includes(group[0].seq)}
+ onSelect={() => toggleSelectGroup(group[0].seq)}
+ onChangeRole={(role) => {
+ // 단일 그룹일 때만 역할 변경 허용
+ if (group.length > 1) return;
+ const gid = group[0].id;
+ const updated = aplns.map((a) => (a.id === gid ? { ...a, role } : a));
+ onChange(updated);
+ }}
+ />
+ ))}
+ </div>
+ </SortableContext>
+ </DndContext>
+ ) : (
+ <div className="text-center py-8 text-gray-500">
+ <p>결재자를 추가해주세요</p>
+ </div>
+ )}
+ </div>
+
+ <Separator className="my-4" />
+ </div>
+ );
+}
+
+export default ApprovalLineSelector;
+
+
diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx
index 8b58aba6..bfe66981 100644
--- a/components/knox/approval/ApprovalSubmit.tsx
+++ b/components/knox/approval/ApprovalSubmit.tsx
@@ -1,1092 +1,1294 @@
-'use client'
-
-import { useState, useEffect } from 'react';
-import { useForm } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
-import { z } from 'zod';
-import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
-import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
-import { Input } from '@/components/ui/input';
-
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-import { Switch } from '@/components/ui/switch';
-import { Badge } from '@/components/ui/badge';
-import { Separator } from '@/components/ui/separator';
-import { Checkbox } from '@/components/ui/checkbox';
-import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
-import { toast } from 'sonner';
-import { Loader2, Trash2, FileText, AlertCircle, GripVertical } from 'lucide-react';
-import { debugLog, debugError } from '@/lib/debug-utils'
+"use client";
+
+import { useState, useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+import { Separator } from "@/components/ui/separator";
+import { Checkbox } from "@/components/ui/checkbox";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import { toast } from "sonner";
+import {
+ Loader2,
+ Trash2,
+ FileText,
+ AlertCircle,
+ GripVertical,
+} from "lucide-react";
+import { debugLog, debugError } from "@/lib/debug-utils";
// dnd-kit imports for drag and drop
import {
- DndContext,
- closestCenter,
- KeyboardSensor,
- PointerSensor,
- useSensor,
- useSensors,
- type DragEndEvent,
-} from '@dnd-kit/core';
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from "@dnd-kit/core";
// 드래그 이동을 수직 축으로 제한하고, 리스트 영역 밖으로 벗어나지 않도록 하는 modifier
import {
- restrictToVerticalAxis,
- restrictToParentElement,
-} from '@dnd-kit/modifiers';
+ restrictToVerticalAxis,
+ restrictToParentElement,
+} from "@dnd-kit/modifiers";
import {
- arrayMove,
- SortableContext,
- sortableKeyboardCoordinates,
- verticalListSortingStrategy,
- useSortable,
-} from '@dnd-kit/sortable';
-import {
- CSS,
-} from '@dnd-kit/utilities';
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+ useSortable,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
// API 함수 및 타입
-import { submitApproval, submitSecurityApproval, createSubmitApprovalRequest, createApprovalLine } from '@/lib/knox-api/approval/approval';
-import type { ApprovalLine, SubmitApprovalRequest } from '@/lib/knox-api/approval/approval';
+import {
+ submitApproval,
+ submitSecurityApproval,
+ createSubmitApprovalRequest,
+ createApprovalLine,
+} from "@/lib/knox-api/approval/approval";
+import type {
+ ApprovalLine,
+ SubmitApprovalRequest,
+} from "@/lib/knox-api/approval/approval";
// 역할 텍스트 매핑 (기존 mock util 대체)
const getRoleText = (role: string) => {
- const map: Record<string, string> = {
- '0': '기안',
- '1': '결재',
- '2': '합의',
- '3': '후결',
- '4': '병렬합의',
- '7': '병렬결재',
- '9': '통보',
- };
- return map[role] || role;
+ const map: Record<string, string> = {
+ "0": "기안",
+ "1": "결재",
+ "2": "합의",
+ "3": "후결",
+ "4": "병렬합의",
+ "7": "병렬결재",
+ "9": "통보",
+ };
+ return map[role] || role;
};
// TiptapEditor 컴포넌트
-import RichTextEditor from '@/components/rich-text-editor/RichTextEditor';
+import RichTextEditor from "@/components/rich-text-editor/RichTextEditor";
// UserSelector 컴포넌트
-import { UserSelector, type UserSelectItem } from '@/components/common/user/user-selector';
-import { useSession } from 'next-auth/react';
+import {
+ UserSelector,
+ type UserSelectItem,
+} from "@/components/common/user/user-selector";
+import { useSession } from "next-auth/react";
// UserSelector에서 반환되는 사용자에 epId가 포함될 수 있으므로 확장 타입 정의
interface ExtendedUserSelectItem extends UserSelectItem {
- epId?: string;
+ epId?: string;
}
// 역할 코드 타입 정의
-type ApprovalRole = '0' | '1' | '2' | '3' | '4' | '7' | '9';
+type ApprovalRole = "0" | "1" | "2" | "3" | "4" | "7" | "9";
// 결재 라인 아이템 타입 정의 (고유 ID 포함)
interface ApprovalLineItem {
- id: string; // 내부 고유 식별자
- epId?: string; // Knox 고유 ID (전사 고유)
- userId?: string; // DB User PK
- emailAddress?: string;
- name?: string; // 사용자 이름
- deptName?: string; // 부서명
- role: ApprovalRole;
- seq: string;
- opinion?: string;
+ id: string; // 내부 고유 식별자
+ epId?: string; // Knox 고유 ID (전사 고유)
+ userId?: string; // DB User PK
+ emailAddress?: string;
+ name?: string; // 사용자 이름
+ deptName?: string; // 부서명
+ role: ApprovalRole;
+ seq: string;
+ opinion?: string;
}
const formSchema = z.object({
- subject: z.string().min(1, '제목은 필수입니다'),
- contents: z.string().min(1, '내용은 필수입니다'),
- contentsType: z.literal('HTML'),
- docSecuType: z.enum(['PERSONAL', 'CONFIDENTIAL', 'CONFIDENTIAL_STRICT']),
- urgYn: z.boolean(),
- importantYn: z.boolean(),
- notifyOption: z.enum(['0', '1', '2', '3']),
- docMngSaveCode: z.enum(['0', '1']),
- sbmLang: z.enum(['ko', 'ja', 'zh', 'en']),
- timeZone: z.string().default('GMT+9'),
- aplns: z.array(z.object({
- id: z.string(), // 고유 식별자
- epId: z.string().optional(),
- userId: z.string().optional(),
- emailAddress: z.string().email('유효한 이메일 주소를 입력해주세요').optional(),
- name: z.string().optional(),
- deptName: z.string().optional(),
- role: z.enum(['0', '1', '2', '3', '4', '7', '9']),
- seq: z.string(),
- opinion: z.string().optional()
- })).min(1, '최소 1개의 결재 경로가 필요합니다'),
- // 첨부파일 (선택)
- attachments: z.any().optional()
+ subject: z.string().min(1, "제목은 필수입니다"),
+ contents: z.string().min(1, "내용은 필수입니다"),
+ contentsType: z.literal("HTML"),
+ docSecuType: z.enum(["PERSONAL", "CONFIDENTIAL", "CONFIDENTIAL_STRICT"]),
+ urgYn: z.boolean(),
+ importantYn: z.boolean(),
+ notifyOption: z.enum(["0", "1", "2", "3"]),
+ docMngSaveCode: z.enum(["0", "1"]),
+ sbmLang: z.enum(["ko", "ja", "zh", "en"]),
+ timeZone: z.string().default("GMT+9"),
+ aplns: z
+ .array(
+ z.object({
+ id: z.string(), // 고유 식별자
+ epId: z.string().optional(),
+ userId: z.string().optional(),
+ emailAddress: z
+ .string()
+ .email("유효한 이메일 주소를 입력해주세요")
+ .optional(),
+ name: z.string().optional(),
+ deptName: z.string().optional(),
+ role: z.enum(["0", "1", "2", "3", "4", "7", "9"]),
+ seq: z.string(),
+ opinion: z.string().optional(),
+ }),
+ )
+ .min(1, "최소 1개의 결재 경로가 필요합니다"),
+ // 첨부파일 (선택)
+ attachments: z.any().optional(),
});
type FormData = z.infer<typeof formSchema>;
interface ApprovalSubmitProps {
- onSubmitSuccess?: (apInfId: string) => void;
+ onSubmitSuccess?: (apInfId: string) => void;
}
// Sortable한 결재 라인 컴포넌트
interface SortableApprovalLineProps {
- apln: ApprovalLineItem;
- index: number;
- form: ReturnType<typeof useForm<FormData>>;
- onRemove: () => void;
- canRemove: boolean;
- selected: boolean;
- onSelect: () => void;
+ apln: ApprovalLineItem;
+ index: number;
+ form: ReturnType<typeof useForm<FormData>>;
+ onRemove: () => void;
+ canRemove: boolean;
+ selected: boolean;
+ onSelect: () => void;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
-function SortableApprovalLine({ apln, index, form, onRemove, canRemove, selected, onSelect }: SortableApprovalLineProps) {
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: apln.id }); // 고유 ID 사용
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.5 : 1, // 드래그 중일 때 투명도 조절
- };
-
-
- return (
- <div
- ref={setNodeRef}
- style={style}
- className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? 'ring-2 ring-primary' : ''}`}
- >
- {/* 드래그 핸들 */}
- <div
- {...attributes}
- {...listeners}
- className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
- >
- <GripVertical className="w-5 h-5" />
- </div>
-
- {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */}
- {index !== 0 ? (
- <Checkbox
- checked={selected}
- onCheckedChange={() => onSelect()}
- onClick={(e) => e.stopPropagation()}
- />
- ) : (
- <div className="w-4 h-4" /> // 기안자용 빈 공간 (체크박스가 없으므로)
- )}
-
- {/* 실제 seq 기준 표시 */}
- <Badge variant="outline">{parseInt(apln.seq) + 1}</Badge>
-
- <div className="flex-1 grid grid-cols-4 gap-3">
- {/* 사용자 정보 표시 */}
- <div className="flex items-center space-x-2">
- <div>
- <div className="font-medium text-sm">
- {(apln.name || 'Knox 이름 없음')}{apln.deptName ? ` / ${apln.deptName}` : ''}
+function SortableApprovalLine({
+ apln,
+ index,
+ form,
+ onRemove,
+ canRemove,
+ selected,
+ onSelect,
+}: SortableApprovalLineProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: apln.id }); // 고유 ID 사용
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1, // 드래그 중일 때 투명도 조절
+ };
+
+ return (
+ <div
+ ref={setNodeRef}
+ style={style}
+ className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`}
+ >
+ {/* 드래그 핸들 */}
+ <div
+ {...attributes}
+ {...listeners}
+ className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
+ >
+ <GripVertical className="w-5 h-5" />
</div>
- </div>
- <FormField
- control={form.control}
- name={`aplns.${index}.id`}
- render={() => (
- <FormItem className="hidden">
- <FormControl>
- <Input type="hidden" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name={`aplns.${index}.epId`}
- render={() => (
- <FormItem className="hidden">
- <FormControl>
- <Input type="hidden" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name={`aplns.${index}.userId`}
- render={() => (
- <FormItem className="hidden">
- <FormControl>
- <Input type="hidden" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name={`aplns.${index}.emailAddress`}
- render={() => (
- <FormItem className="hidden">
- <FormControl>
- <Input type="hidden" />
- </FormControl>
- <FormMessage />
- </FormItem>
+
+ {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */}
+ {index !== 0 ? (
+ <Checkbox
+ checked={selected}
+ onCheckedChange={() => onSelect()}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+ <div className="w-4 h-4" /> // 기안자용 빈 공간 (체크박스가 없으므로)
)}
- />
- </div>
- {/* 역할 선택 */}
- {index === 0 ? (
- // 상신자는 역할 선택 대신 고정 표시
- <div className="flex items-center">
- <Badge variant="secondary">기안</Badge>
- </div>
- ) : (
- <FormField
- control={form.control}
- name={`aplns.${index}.role`}
- render={({ field }) => {
- // 병렬 여부 판단
- const isParallel = field.value === '4' || field.value === '7';
-
- // 병렬, 후결 값을 제외한 기본 역할
- const baseRole: ApprovalRole = field.value === '7' ? '1' : field.value === '4' ? '2' : field.value === '3' ? '1' : field.value as ApprovalRole;
-
- // 기본 역할 변경 핸들러
- const handleBaseRoleChange = (val: string) => {
- if (!val) return;
- let newRole = val;
- if (isParallel) {
- if (val === '1') newRole = '7';
- else if (val === '2') newRole = '4';
- }
- field.onChange(newRole);
- };
-
- // 병렬인 경우 한 개 버튼으로 표시
- if (isParallel) {
- return (
- <FormItem className="w-full">
- <Badge className="w-full justify-center" variant="secondary">
- {getRoleText(field.value)}
- </Badge>
- </FormItem>
- );
- }
-
- return (
- <FormItem>
- <div className="flex flex-col gap-2">
- <ToggleGroup
- type="single"
- value={baseRole}
- onValueChange={handleBaseRoleChange}
- >
- <ToggleGroupItem value="1">결재</ToggleGroupItem>
- <ToggleGroupItem value="2">합의</ToggleGroupItem>
- <ToggleGroupItem value="9">통보</ToggleGroupItem>
- </ToggleGroup>
- </div>
- <FormMessage />
- </FormItem>
- );
- }}
- />
- )}
-
- {/* 의견 입력란 제거됨 */}
-
- {/* 역할 표시 */}
- <div className="flex items-center justify-between">
- <Badge variant="secondary">
- {getRoleText(apln.role)}
- </Badge>
-
- {canRemove && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={onRemove}
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- )}
+ {/* 실제 seq 기준 표시 */}
+ <Badge variant="outline">{parseInt(apln.seq) + 1}</Badge>
+
+ <div className="flex-1 grid grid-cols-4 gap-3">
+ {/* 사용자 정보 표시 */}
+ <div className="flex items-center space-x-2">
+ <div>
+ <div className="font-medium text-sm">
+ {apln.name || "Knox 이름 없음"}
+ {apln.deptName ? ` / ${apln.deptName}` : ""}
+ </div>
+ </div>
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.id`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.epId`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.userId`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.emailAddress`}
+ render={() => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input type="hidden" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 역할 선택 */}
+ {index === 0 ? (
+ // 상신자는 역할 선택 대신 고정 표시
+ <div className="flex items-center">
+ <Badge variant="secondary">기안</Badge>
+ </div>
+ ) : (
+ <FormField
+ control={form.control}
+ name={`aplns.${index}.role`}
+ render={({ field }) => {
+ // 병렬 여부 판단
+ const isParallel =
+ field.value === "4" || field.value === "7";
+
+ // 병렬, 후결 값을 제외한 기본 역할
+ const baseRole: ApprovalRole =
+ field.value === "7"
+ ? "1"
+ : field.value === "4"
+ ? "2"
+ : field.value === "3"
+ ? "1"
+ : (field.value as ApprovalRole);
+
+ // 기본 역할 변경 핸들러
+ const handleBaseRoleChange = (val: string) => {
+ if (!val) return;
+ let newRole = val;
+ if (isParallel) {
+ if (val === "1") newRole = "7";
+ else if (val === "2") newRole = "4";
+ }
+ field.onChange(newRole);
+ };
+
+ // 병렬인 경우 한 개 버튼으로 표시
+ if (isParallel) {
+ return (
+ <FormItem className="w-full">
+ <Badge
+ className="w-full justify-center"
+ variant="secondary"
+ >
+ {getRoleText(field.value)}
+ </Badge>
+ </FormItem>
+ );
+ }
+
+ return (
+ <FormItem>
+ <div className="flex flex-col gap-2">
+ <ToggleGroup
+ type="single"
+ value={baseRole}
+ onValueChange={handleBaseRoleChange}
+ >
+ <ToggleGroupItem value="1">
+ 결재
+ </ToggleGroupItem>
+ <ToggleGroupItem value="2">
+ 합의
+ </ToggleGroupItem>
+ <ToggleGroupItem value="9">
+ 통보
+ </ToggleGroupItem>
+ </ToggleGroup>
+ </div>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ )}
+
+ {/* 의견 입력란 제거됨 */}
+
+ {/* 역할 표시 */}
+ <div className="flex items-center justify-between">
+ <Badge variant="secondary">{getRoleText(apln.role)}</Badge>
+
+ {canRemove && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={onRemove}
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ )}
+ </div>
+ </div>
</div>
- </div>
- </div>
- );
+ );
}
// Sortable Approval Group (seq 단위 카드)
interface SortableApprovalGroupProps {
- group: ApprovalLineItem[]; // 동일 seq 항목들
- index: number;
- form: ReturnType<typeof useForm<FormData>>;
- onRemoveGroup: () => void;
- canRemove: boolean;
- selected: boolean;
- onSelect: () => void;
+ group: ApprovalLineItem[]; // 동일 seq 항목들
+ index: number;
+ form: ReturnType<typeof useForm<FormData>>;
+ onRemoveGroup: () => void;
+ canRemove: boolean;
+ selected: boolean;
+ onSelect: () => void;
}
-function SortableApprovalGroup({ group, index, form, onRemoveGroup, canRemove, selected, onSelect }: SortableApprovalGroupProps) {
- const seq = group[0].seq;
- const role = group[0].role;
- // 그룹을 식별할 안정적인 고유 키(첫 구성원의 id 활용)
- const groupKey = group[0].id;
-
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: groupKey });
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.5 : 1,
- };
-
- return (
- <div
- ref={setNodeRef}
- style={style}
- className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? 'ring-2 ring-primary' : ''}`}
- >
- {/* 드래그 핸들 */}
- <div
- {...attributes}
- {...listeners}
- className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
- >
- <GripVertical className="w-5 h-5" />
- </div>
-
- {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */}
- {index !== 0 ? (
- <Checkbox
- checked={selected}
- onCheckedChange={onSelect}
- onClick={(e) => e.stopPropagation()}
- />
- ) : (
- <div className="w-4 h-4" /> // 기안자용 빈 공간
- )}
-
- {/* seq 표시 */}
- <Badge variant="outline">{parseInt(seq) + 1}</Badge>
-
- {/* 그룹 상세 정보 */}
- <div className="flex-1 grid grid-cols-3 gap-3">
- {/* 사용자 목록 */}
- <div className="flex flex-col justify-center gap-1">
- {group.map((u) => (
- <div key={u.id} className="text-sm">
- {(u.name || 'Knox 이름 없음')}{u.deptName ? ` / ${u.deptName}` : ''}
+function SortableApprovalGroup({
+ group,
+ index,
+ form,
+ onRemoveGroup,
+ canRemove,
+ selected,
+ onSelect,
+}: SortableApprovalGroupProps) {
+ const seq = group[0].seq;
+ const role = group[0].role;
+ // 그룹을 식별할 안정적인 고유 키(첫 구성원의 id 활용)
+ const groupKey = group[0].id;
+
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: groupKey });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+ <div
+ ref={setNodeRef}
+ style={style}
+ className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`}
+ >
+ {/* 드래그 핸들 */}
+ <div
+ {...attributes}
+ {...listeners}
+ className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600"
+ >
+ <GripVertical className="w-5 h-5" />
</div>
- ))}
- </div>
- {/* 역할 */}
- <div className="flex items-center">
- {seq === '0' ? (
- <Badge variant="secondary" className="w-full justify-center">
- 기안
- </Badge>
- ) : role === '7' || role === '4' ? (
- <Badge variant="secondary" className="w-full justify-center">
- {getRoleText(role)}
- </Badge>
- ) : (
- // 단일일 때는 기존 토글 재사용 (첫 항목 기준)
- <FormField
- control={form.control}
- name={`aplns.${form.getValues('aplns').findIndex((a) => a.id === group[0].id)}.role`}
- render={({ field }) => (
- <ToggleGroup
- type="single"
- value={field.value}
- onValueChange={field.onChange}
- >
- <ToggleGroupItem value="1">결재</ToggleGroupItem>
- <ToggleGroupItem value="2">합의</ToggleGroupItem>
- <ToggleGroupItem value="9">통보</ToggleGroupItem>
- </ToggleGroup>
- )}
- />
- )}
- </div>
-
- {/* 삭제 버튼 */}
- <div className="flex items-center justify-end">
- {canRemove && (
- <Button type="button" variant="ghost" size="sm" onClick={onRemoveGroup}>
- <Trash2 className="w-4 h-4" />
- </Button>
- )}
- </div>
- </div>
- </div>
- );
-}
+ {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */}
+ {index !== 0 ? (
+ <Checkbox
+ checked={selected}
+ onCheckedChange={onSelect}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+ <div className="w-4 h-4" /> // 기안자용 빈 공간
+ )}
-export default function ApprovalSubmit({ onSubmitSuccess }: ApprovalSubmitProps) {
- const { data: session } = useSession();
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [submitResult, setSubmitResult] = useState<{ apInfId: string } | null>(null);
+ {/* seq 표시 */}
+ <Badge variant="outline">{parseInt(seq) + 1}</Badge>
+
+ {/* 그룹 상세 정보 */}
+ <div className="flex-1 grid grid-cols-3 gap-3">
+ {/* 사용자 목록 */}
+ <div className="flex flex-col justify-center gap-1">
+ {group.map((u) => (
+ <div key={u.id} className="text-sm">
+ {u.name || "Knox 이름 없음"}
+ {u.deptName ? ` / ${u.deptName}` : ""}
+ </div>
+ ))}
+ </div>
- const [selectedSeqs, setSelectedSeqs] = useState<string[]>([]);
+ {/* 역할 */}
+ <div className="flex items-center">
+ {seq === "0" ? (
+ <Badge
+ variant="secondary"
+ className="w-full justify-center"
+ >
+ 기안
+ </Badge>
+ ) : role === "7" || role === "4" ? (
+ <Badge
+ variant="secondary"
+ className="w-full justify-center"
+ >
+ {getRoleText(role)}
+ </Badge>
+ ) : (
+ // 단일일 때는 기존 토글 재사용 (첫 항목 기준)
+ <FormField
+ control={form.control}
+ name={`aplns.${form.getValues("aplns").findIndex((a) => a.id === group[0].id)}.role`}
+ render={({ field }) => (
+ <ToggleGroup
+ type="single"
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <ToggleGroupItem value="1">
+ 결재
+ </ToggleGroupItem>
+ <ToggleGroupItem value="2">
+ 합의
+ </ToggleGroupItem>
+ <ToggleGroupItem value="9">
+ 통보
+ </ToggleGroupItem>
+ </ToggleGroup>
+ )}
+ />
+ )}
+ </div>
- // 그룹 단위 선택/해제
- const toggleSelectGroup = (seq: string) => {
- setSelectedSeqs((prev) =>
- prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq]
+ {/* 삭제 버튼 */}
+ <div className="flex items-center justify-end">
+ {canRemove && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={onRemoveGroup}
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
);
- };
- const clearSelection = () => setSelectedSeqs([]);
-
- const form = useForm<FormData>({
- resolver: zodResolver(formSchema),
- defaultValues: {
- subject: '',
- contents: '',
- contentsType: 'HTML',
- docSecuType: 'PERSONAL',
- urgYn: false,
- importantYn: false,
- notifyOption: '0',
- docMngSaveCode: '0',
- sbmLang: 'ko',
- timeZone: 'GMT+9',
- aplns: [],
- attachments: undefined
- }
- });
-
- const aplns = form.watch('aplns');
-
- // 병렬 전환 핸들러
- const applyParallel = () => {
- if (selectedSeqs.length < 2) {
- toast.error('두 명 이상 선택해야 병렬 지정이 가능합니다.');
- return;
- }
-
- const current = form.getValues('aplns');
- const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq));
-
- const roles = Array.from(new Set(selectedAplns.map((a) => a.role)));
- if (roles.length !== 1) {
- toast.error('선택된 항목의 역할이 동일해야 합니다.');
- return;
- }
-
- const role = roles[0];
- let newRole: ApprovalRole;
- if (role === '1') {
- newRole = '7'; // 병렬 결재
- } else if (role === '2') {
- newRole = '4'; // 병렬 합의
- } else if (role === '9') {
- newRole = '9'; // 병렬 통보(역할 코드 유지)
- } else {
- toast.error('결재, 합의 또는 통보만 병렬 지정 가능합니다.');
- return;
- }
-
- const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq)));
-
- const updated = current.map((a) => {
- if (selectedSeqs.includes(a.seq)) {
- return { ...a, role: newRole as ApprovalRole, seq: minSeq.toString() };
- }
- return a;
- });
-
- form.setValue('aplns', reorderBySeq(updated), { shouldDirty: true });
- clearSelection();
- };
-
- // 후결 전환 핸들러
- const applyAfter = () => {
- if (selectedSeqs.length !== 1) {
- toast.error('후결은 한 명만 지정할 수 있습니다.');
- return;
- }
-
- const targetSeq = selectedSeqs[0];
-
- // 병렬 그룹(결재:7, 합의:4)은 후결 전환 불가
- const targetRole = form.getValues('aplns').find((a) => a.seq === targetSeq)?.role;
- if (targetRole === '7' || targetRole === '4') {
- toast.error('병렬 그룹은 후결로 전환할 수 없습니다.');
- return;
- }
-
- const updated = form.getValues('aplns').map((a) => {
- if (a.seq === targetSeq) {
- return { ...a, role: (a.role === '3' ? '1' : '3') as ApprovalRole };
- }
- return a;
- });
-
- form.setValue('aplns', reorderBySeq(updated), { shouldDirty: true });
- clearSelection();
- };
-
- // 병렬 해제 핸들러
- const ungroupParallel = () => {
- if (selectedSeqs.length === 0) {
- toast.error('해제할 결재선을 선택하세요.');
- return;
- }
-
- let newSeqCounter = 1; // 0은 상신자 유지
- const updated = form.getValues('aplns').map((a) => {
- if (selectedSeqs.includes(a.seq)) {
- let newRole: ApprovalRole = a.role;
- if (a.role === '7') newRole = '1';
- if (a.role === '4') newRole = '2';
-
- return { ...a, role: newRole, seq: '' }; // seq 임시 비움
- }
- return { ...a };
- });
+}
- // seq 재할당 (상신자 제외하고 순차)
- const reassigned = updated
- .sort((x, y) => parseInt(x.seq || '0') - parseInt(y.seq || '0'))
- .map((a) => {
- if (a.seq === '0') return a; // 상신자
- const newItem = { ...a, seq: newSeqCounter.toString() };
- newSeqCounter += 1;
- return newItem;
- });
-
- form.setValue('aplns', reassigned as FormData['aplns'], { shouldDirty: true });
- clearSelection();
- };
-
- // seq 기준 정렬 및 재번호 부여 (병렬 그룹은 동일 seq 유지)
- const reorderBySeq = (list: FormData['aplns']): FormData['aplns'] => {
- const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq));
-
- // 기존 seq -> 새 seq 매핑. 병렬 그룹(동일 seq)은 동일한 새 seq 를 갖도록 처리
- const seqMap = new Map<string, string>();
- let nextSeq = 0;
-
- return sorted.map((apln) => {
- if (!seqMap.has(apln.seq)) {
- seqMap.set(apln.seq, nextSeq.toString());
- nextSeq += 1;
- }
- return { ...apln, seq: seqMap.get(apln.seq)! };
+export default function ApprovalSubmit({
+ onSubmitSuccess,
+}: ApprovalSubmitProps) {
+ const { data: session } = useSession();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitResult, setSubmitResult] = useState<{
+ apInfId: string;
+ } | null>(null);
+
+ const [selectedSeqs, setSelectedSeqs] = useState<string[]>([]);
+
+ // 그룹 단위 선택/해제
+ const toggleSelectGroup = (seq: string) => {
+ setSelectedSeqs((prev) =>
+ prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq],
+ );
+ };
+ const clearSelection = () => setSelectedSeqs([]);
+
+ const form = useForm<FormData>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ subject: "",
+ contents: "",
+ contentsType: "HTML",
+ docSecuType: "PERSONAL",
+ urgYn: false,
+ importantYn: false,
+ notifyOption: "0",
+ docMngSaveCode: "0",
+ sbmLang: "ko",
+ timeZone: "GMT+9",
+ aplns: [],
+ attachments: undefined,
+ },
});
- };
-
- // 로그인 사용자를 첫 번째 결재자로 보장하는 effect
- useEffect(() => {
- if (!session?.user) return;
-
- const currentEmail = session.user.email ?? '';
- const currentEpId = (session.user as { epId?: string }).epId;
- const currentUserId = session.user.id ?? undefined;
- let currentAplns = form.getValues('aplns');
+ const aplns = form.watch("aplns");
- // 이미 포함되어 있는지 확인 (epId 또는 email 기준)
- const selfIndex = currentAplns.findIndex(
- (a) => (currentEpId && a.epId === currentEpId) || a.emailAddress === currentEmail
- );
-
- if (selfIndex === -1) {
- // 맨 앞에 상신자 추가
- const newSelf: FormData['aplns'][number] = {
- id: generateUniqueId(),
- epId: currentEpId,
- userId: currentUserId ? currentUserId.toString() : undefined,
- emailAddress: currentEmail,
- name: session.user.name ?? undefined,
- role: '0', // 기안
- seq: '0',
- opinion: ''
- };
-
- currentAplns = [newSelf, ...currentAplns];
- }
-
- // seq 재정렬 보장
- currentAplns = currentAplns.map((apln, idx) => ({ ...apln, seq: idx.toString() }));
-
- form.setValue('aplns', currentAplns, { shouldValidate: false, shouldDirty: true });
- }, [session, form]);
-
- // dnd-kit sensors
- const sensors = useSensors(
- useSensor(PointerSensor, {
- activationConstraint: {
- distance: 8,
- },
- }),
- useSensor(KeyboardSensor, {
- coordinateGetter: sortableKeyboardCoordinates,
- })
- );
-
- // 고유 ID 생성 함수
- const generateUniqueId = () => {
- return `apln-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
- };
-
- // 결재자 추가 (UserSelector를 통해)
- const addApprovalUsers = (users: UserSelectItem[]) => {
- const newAplns = [...aplns];
-
- users.forEach((user) => {
- // 이미 추가된 사용자인지 확인
- const existingIndex = newAplns.findIndex(apln => apln.userId === user.id.toString());
- if (existingIndex === -1) {
- // 새 사용자 추가
- const newSeq = (newAplns.length).toString(); // 0은 상신자
- const newApln: FormData['aplns'][number] = {
- id: generateUniqueId(), // 고유 ID 생성
- epId: (user as ExtendedUserSelectItem).epId, // epId가 전달되면 사용
- userId: user.id.toString(),
- emailAddress: user.email,
- name: user.name,
- deptName: (user as ExtendedUserSelectItem).deptName ?? undefined,
- role: '1', // 기본값: 결재
- seq: newSeq,
- opinion: ''
- };
- newAplns.push(newApln);
- }
- });
-
- form.setValue('aplns', newAplns);
- };
-
- // 그룹 삭제 (seq 기반)
- const removeApprovalGroup = (seq: string) => {
- if (seq === '0') return; // 상신자 삭제 불가
-
- const remaining = aplns.filter((a) => a.seq !== seq);
-
- // seq 재정렬 (병렬 그룹 유지)
- const reordered = reorderBySeq(remaining);
- form.setValue('aplns', reordered);
- };
-
- // 기존 단일 삭제 로직은 더 이상 사용하지 않음 (호환 위해 남겨둠)
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const removeApprovalLine = (index: number) => {
- // 첫 번째(상신자)는 삭제 불가
- if (index === 0) return;
-
- if (aplns.length > 1) {
- const newAplns = aplns.filter((_: FormData['aplns'][number], i: number) => i !== index);
- // 순서 재정렬 (ID는 유지)
- const reorderedAplns = newAplns.map((apln: FormData['aplns'][number], i: number) => ({
- ...apln,
- seq: (i).toString()
- }));
- form.setValue('aplns', reorderedAplns);
- }
- };
-
- // 드래그앤드롭 핸들러 (그룹 이동 지원)
- const handleDragEnd = (event: DragEndEvent) => {
- const { active, over } = event;
-
- if (!over || active.id === over.id) return;
-
- // 현재 id는 그룹의 고유 key(첫 라인 id)
- const activeKey = active.id as string;
- const overKey = over.id as string;
-
- // key → seq 매핑 생성
- const idToSeq = new Map<string, string>();
- aplns.forEach((a) => {
- // 같은 seq 내 첫 번째 id만 매핑하면 충분하지만, 안전하게 모두 매핑
- idToSeq.set(a.id, a.seq);
- });
+ // 병렬 전환 핸들러
+ const applyParallel = () => {
+ if (selectedSeqs.length < 2) {
+ toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다.");
+ return;
+ }
- const activeSeq = idToSeq.get(activeKey);
- const overSeq = idToSeq.get(overKey);
+ const current = form.getValues("aplns");
+ const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq));
- if (!activeSeq || !overSeq) return;
+ const roles = Array.from(new Set(selectedAplns.map((a) => a.role)));
+ if (roles.length !== 1) {
+ toast.error("선택된 항목의 역할이 동일해야 합니다.");
+ return;
+ }
- if (activeSeq === '0' || overSeq === '0') return; // 상신자는 이동 불가
+ const role = roles[0];
+ let newRole: ApprovalRole;
+ if (role === "1") {
+ newRole = "7"; // 병렬 결재
+ } else if (role === "2") {
+ newRole = "4"; // 병렬 합의
+ } else if (role === "9") {
+ newRole = "9"; // 병렬 통보(역할 코드 유지)
+ } else {
+ toast.error("결재, 합의 또는 통보만 병렬 지정 가능합니다.");
+ return;
+ }
- // 현재 그룹 순서를 key 기반으로 계산
- const seqOrder = Array.from(new Set(aplns.map((a) => a.seq)));
- const keyOrder = seqOrder.map((seq) => {
- return aplns.find((a) => a.seq === seq)!.id;
- });
+ const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq)));
- const oldIndex = keyOrder.indexOf(activeKey);
- const newIndex = keyOrder.indexOf(overKey);
+ const updated = current.map((a) => {
+ if (selectedSeqs.includes(a.seq)) {
+ return { ...a, role: newRole as ApprovalRole, seq: minSeq.toString() };
+ }
+ return a;
+ });
- const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex);
+ form.setValue("aplns", reorderBySeq(updated), { shouldDirty: true });
+ clearSelection();
+ };
- // key → 새 seq 매핑
- const keyToNewSeq = new Map<string, string>();
- newKeyOrder.forEach((k, idx) => {
- keyToNewSeq.set(k, idx.toString());
- });
+ // 후결 전환 핸들러
+ const applyAfter = () => {
+ if (selectedSeqs.length !== 1) {
+ toast.error("후결은 한 명만 지정할 수 있습니다.");
+ return;
+ }
- // aplns 재구성 + seq 재할당
- const updatedAplns: FormData['aplns'] = [];
- newKeyOrder.forEach((k) => {
- const oldSeq = idToSeq.get(k)!;
- const groupItems = aplns.filter((a) => a.seq === oldSeq);
- groupItems.forEach((item) => {
- updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! });
- });
- });
+ const targetSeq = selectedSeqs[0];
- form.setValue('aplns', updatedAplns, { shouldValidate: false, shouldDirty: true });
- };
-
- const onSubmit = async (data: FormData) => {
- setIsSubmitting(true);
- setSubmitResult(null);
-
- try {
- // 세션 정보 확인
- if (!session?.user) {
- toast.error('로그인이 필요합니다.');
- return;
- }
-
- const currentEmail = session.user.email ?? '';
- const currentEpId = (session.user as { epId?: string }).epId;
- const currentUserId = session.user.id ?? '';
-
- debugLog('Current User', session.user);
-
- if (!currentEpId) {
- toast.error('사용자 정보가 올바르지 않습니다.');
- return;
- }
-
- // 결재 경로 생성 (ID 제거하고 API 호출)
- const approvalLines: ApprovalLine[] = await Promise.all(
- data.aplns.map((apln) =>
- createApprovalLine(
- { epId: apln.epId },
- apln.role,
- apln.seq,
- { opinion: apln.opinion }
- )
- )
- );
-
- debugLog('Approval Lines', approvalLines);
-
- // 상신 요청 생성
- const attachmentsArray = data.attachments ? Array.from(data.attachments as FileList) : undefined;
-
- const submitRequest: SubmitApprovalRequest = await createSubmitApprovalRequest(
- data.contents,
- data.subject,
- approvalLines,
- {
- contentsType: data.contentsType,
- docSecuType: data.docSecuType,
- urgYn: data.urgYn ? 'Y' : 'N',
- importantYn: data.importantYn ? 'Y' : 'N',
- notifyOption: data.notifyOption,
- docMngSaveCode: data.docMngSaveCode,
- sbmLang: data.sbmLang,
- timeZone: data.timeZone,
- attachments: attachmentsArray
+ // 병렬 그룹(결재:7, 합의:4)은 후결 전환 불가
+ const targetRole = form.getValues("aplns").find((a) => a.seq === targetSeq)?.role;
+ if (targetRole === "7" || targetRole === "4") {
+ toast.error("병렬 그룹은 후결로 전환할 수 없습니다.");
+ return;
}
- );
-
- // API 호출 (보안 등급에 따라 분기)
- const isSecure = data.docSecuType === 'CONFIDENTIAL' || data.docSecuType === 'CONFIDENTIAL_STRICT';
-
- debugLog('Submit Request', submitRequest);
-
- const response = isSecure
- ? await submitSecurityApproval(submitRequest)
- : await submitApproval(submitRequest, {
- userId: currentUserId,
- epId: currentEpId,
- emailAddress: currentEmail
- });
-
- debugLog('Submit Response', response);
-
- if (response.result === 'SUCCESS') {
- setSubmitResult({ apInfId: response.data.apInfId });
- toast.success('결재가 성공적으로 상신되었습니다.');
- onSubmitSuccess?.(response.data.apInfId);
- form.reset();
- } else {
- toast.error(`결재 상신에 실패했습니다: ${response.result}`);
- }
- } catch (error) {
- debugError('결재 상신 오류', error);
- toast.error('결재 상신 중 오류가 발생했습니다.');
- } finally {
- setIsSubmitting(false);
- }
- };
-
- return (
- <Card className="w-full max-w-5xl">
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- 결재 상신
- </CardTitle>
- <CardDescription>
- 새로운 결재를 상신합니다.
- </CardDescription>
- </CardHeader>
-
- <CardContent className="space-y-6">
- {submitResult && (
- <div className="p-4 bg-green-50 border border-green-200 rounded-lg">
- <div className="flex items-center gap-2 text-green-700">
- <AlertCircle className="w-4 h-4" />
- <span className="font-medium">상신 완료</span>
- </div>
- <p className="text-sm text-green-600 mt-1">
- 결재 ID: {submitResult.apInfId}
- </p>
- </div>
- )}
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- {/* 기본 정보 */}
- <div className="space-y-4">
-
- <Separator />
-
- {/* 결재 경로 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold">결재 경로</h3>
-
- {/* 상단 제어 버튼 */}
- <div className="flex justify-end gap-2 mb-2">
- <Button variant="outline" size="sm" onClick={applyParallel}>병렬</Button>
- <Button variant="outline" size="sm" onClick={applyAfter}>후결</Button>
- <Button variant="outline" size="sm" onClick={ungroupParallel}>해제</Button>
- </div>
- {/* 결재자 추가 섹션 */}
- <div className="p-4 border border-dashed border-gray-300 rounded-lg">
- <div className="mb-2">
- <label className="text-sm font-medium text-gray-700">결재자 추가</label>
- <p className="text-xs text-gray-500">사용자를 검색하여 결재 라인에 추가하세요</p>
- </div>
- <UserSelector
- selectedUsers={[]}
- onUsersChange={addApprovalUsers}
- placeholder="결재자를 검색하세요..."
- domainFilter={{ type: "exclude", domains: ["partners"] }}
- maxSelections={10} // 최대 10명까지 추가 가능
- />
- </div>
+ const updated = form.getValues("aplns").map((a) => {
+ if (a.seq === targetSeq) {
+ return { ...a, role: (a.role === "3" ? "1" : "3") as ApprovalRole };
+ }
+ return a;
+ });
+
+ form.setValue("aplns", reorderBySeq(updated), { shouldDirty: true });
+ clearSelection();
+ };
+
+ // 병렬 해제 핸들러
+ const ungroupParallel = () => {
+ if (selectedSeqs.length === 0) {
+ toast.error("해제할 결재선을 선택하세요.");
+ return;
+ }
- {/* 그룹 기반 렌더링 */}
- {aplns.length > 0 && (
- (() => {
- const groups = Object.values(
- aplns.reduce<Record<string, ApprovalLineItem[]>>((acc, apln) => {
- acc[apln.seq] = acc[apln.seq] ? [...acc[apln.seq], apln] : [apln];
- return acc;
- }, {})
- ).sort((a, b) => parseInt(a[0].seq) - parseInt(b[0].seq));
-
- return (
- <DndContext
- sensors={sensors}
- collisionDetection={closestCenter}
- modifiers={[restrictToVerticalAxis, restrictToParentElement]}
- onDragEnd={handleDragEnd}
- >
- <SortableContext items={groups.map(g => g[0].id)} strategy={verticalListSortingStrategy}>
- <div className="space-y-3">
- {groups.map((group, idx) => (
- <SortableApprovalGroup
- key={group[0].id}
- group={group}
- index={idx}
- form={form}
- onRemoveGroup={() => removeApprovalGroup(group[0].seq)}
- canRemove={idx !== 0 && aplns.length > 1}
- selected={selectedSeqs.includes(group[0].seq)}
- onSelect={() => toggleSelectGroup(group[0].seq)}
- />
- ))}
- </div>
- </SortableContext>
- </DndContext>
- );
- })()
- )}
+ let newSeqCounter = 1; // 0은 상신자 유지
+ const updated = form.getValues("aplns").map((a) => {
+ if (selectedSeqs.includes(a.seq)) {
+ let newRole: ApprovalRole = a.role;
+ if (a.role === "7") newRole = "1";
+ if (a.role === "4") newRole = "2";
+
+ return { ...a, role: newRole, seq: "" }; // seq 임시 비움
+ }
+ return { ...a };
+ });
+
+ // seq 재할당 (상신자 제외하고 순차)
+ const reassigned = updated
+ .sort((x, y) => parseInt(x.seq || "0") - parseInt(y.seq || "0"))
+ .map((a) => {
+ if (a.seq === "0") return a; // 상신자
+ const newItem = { ...a, seq: newSeqCounter.toString() };
+ newSeqCounter += 1;
+ return newItem;
+ });
+
+ form.setValue("aplns", reassigned as FormData["aplns"], { shouldDirty: true });
+ clearSelection();
+ };
+
+ // seq 기준 정렬 및 재번호 부여 (병렬 그룹은 동일 seq 유지)
+ const reorderBySeq = (list: FormData["aplns"]): FormData["aplns"] => {
+ const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq));
+
+ // 기존 seq -> 새 seq 매핑. 병렬 그룹(동일 seq)은 동일한 새 seq 를 갖도록 처리
+ const seqMap = new Map<string, string>();
+ let nextSeq = 0;
+
+ return sorted.map((apln) => {
+ if (!seqMap.has(apln.seq)) {
+ seqMap.set(apln.seq, nextSeq.toString());
+ nextSeq += 1;
+ }
+ return { ...apln, seq: seqMap.get(apln.seq)! };
+ });
+ };
+
+ // 로그인 사용자를 첫 번째 결재자로 보장하는 effect
+ useEffect(() => {
+ if (!session?.user) return;
+
+ const currentEmail = session.user.email ?? "";
+ const currentEpId = (session.user as { epId?: string }).epId;
+ const currentUserId = session.user.id ?? undefined;
+
+ let currentAplns = form.getValues("aplns");
+
+ // 이미 포함되어 있는지 확인 (epId 또는 email 기준)
+ const selfIndex = currentAplns.findIndex(
+ (a) => (currentEpId && a.epId === currentEpId) || a.emailAddress === currentEmail,
+ );
+
+ if (selfIndex === -1) {
+ // 맨 앞에 상신자 추가
+ const newSelf: FormData["aplns"][number] = {
+ id: generateUniqueId(),
+ epId: currentEpId,
+ userId: currentUserId ? currentUserId.toString() : undefined,
+ emailAddress: currentEmail,
+ name: session.user.name ?? undefined,
+ role: "0", // 기안
+ seq: "0",
+ opinion: "",
+ };
+
+ currentAplns = [newSelf, ...currentAplns];
+ }
- {aplns.length === 0 && (
- <div className="text-center py-8 text-gray-500">
- <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
- <p>결재자를 추가해주세요</p>
- </div>
- )}
- </div>
-
- <FormField
- control={form.control}
- name="subject"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제목 *</FormLabel>
- <FormControl>
- <Input placeholder="결재 제목을 입력하세요" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="contents"
- render={({ field }) => (
- <FormItem>
- <FormLabel>내용 *</FormLabel>
- <FormControl>
- <RichTextEditor
- value={field.value}
- onChange={field.onChange}
- height="400px"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */}
- <div className="grid grid-cols-3 gap-4">
- {/* 보안 등급 */}
- <FormField
- control={form.control}
- name="docSecuType"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <FormLabel>보안</FormLabel>
- <FormControl>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <SelectTrigger className="w-24">
- <SelectValue placeholder="등급" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="PERSONAL">개인</SelectItem>
- <SelectItem value="CONFIDENTIAL">기밀</SelectItem>
- <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem>
- </SelectContent>
- </Select>
- </FormControl>
- </FormItem>
- )}
- />
+ // seq 재정렬 보장
+ currentAplns = currentAplns.map((apln, idx) => ({ ...apln, seq: idx.toString() }));
+
+ form.setValue("aplns", currentAplns, { shouldValidate: false, shouldDirty: true });
+ }, [session, form]);
+
+ // dnd-kit sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
- {/* 긴급 여부 */}
- <FormField
- control={form.control}
- name="urgYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <FormLabel>긴급</FormLabel>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
+ // 고유 ID 생성 함수
+ const generateUniqueId = () => {
+ return `apln-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+ };
+
+ // 결재자 추가 (UserSelector를 통해)
+ const addApprovalUsers = (users: UserSelectItem[]) => {
+ const newAplns = [...aplns];
+
+ users.forEach((user) => {
+ // 이미 추가된 사용자인지 확인
+ const existingIndex = newAplns.findIndex(apln => apln.userId === user.id.toString());
+ if (existingIndex === -1) {
+ // 새 사용자 추가
+ const newSeq = (newAplns.length).toString(); // 0은 상신자
+ const newApln: FormData["aplns"][number] = {
+ id: generateUniqueId(), // 고유 ID 생성
+ epId: (user as ExtendedUserSelectItem).epId, // epId가 전달되면 사용
+ userId: user.id.toString(),
+ emailAddress: user.email,
+ name: user.name,
+ deptName: (user as ExtendedUserSelectItem).deptName ?? undefined,
+ role: "1", // 기본값: 결재
+ seq: newSeq,
+ opinion: "",
+ };
+ newAplns.push(newApln);
+ }
+ });
+
+ form.setValue("aplns", newAplns);
+ };
+
+ // 그룹 삭제 (seq 기반)
+ const removeApprovalGroup = (seq: string) => {
+ if (seq === "0") return; // 상신자 삭제 불가
+
+ const remaining = aplns.filter((a) => a.seq !== seq);
+
+ // seq 재정렬 (병렬 그룹 유지)
+ const reordered = reorderBySeq(remaining);
+ form.setValue("aplns", reordered);
+ };
+
+ // 기존 단일 삭제 로직은 더 이상 사용하지 않음 (호환 위해 남겨둠)
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const removeApprovalLine = (index: number) => {
+ // 첫 번째(상신자)는 삭제 불가
+ if (index === 0) return;
+
+ if (aplns.length > 1) {
+ const newAplns = aplns.filter((_: FormData["aplns"][number], i: number) => i !== index);
+ // 순서 재정렬 (ID는 유지)
+ const reorderedAplns = newAplns.map((apln: FormData["aplns"][number], i: number) => ({
+ ...apln,
+ seq: (i).toString(),
+ }));
+ form.setValue("aplns", reorderedAplns);
+ }
+ };
+
+ // 드래그앤드롭 핸들러 (그룹 이동 지원)
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (!over || active.id === over.id) return;
+
+ // 현재 id는 그룹의 고유 key(첫 라인 id)
+ const activeKey = active.id as string;
+ const overKey = over.id as string;
+
+ // key → seq 매핑 생성
+ const idToSeq = new Map<string, string>();
+ aplns.forEach((a) => {
+ // 같은 seq 내 첫 번째 id만 매핑하면 충분하지만, 안전하게 모두 매핑
+ idToSeq.set(a.id, a.seq);
+ });
+
+ const activeSeq = idToSeq.get(activeKey);
+ const overSeq = idToSeq.get(overKey);
+
+ if (!activeSeq || !overSeq) return;
+
+ if (activeSeq === "0" || overSeq === "0") return; // 상신자는 이동 불가
+
+ // 현재 그룹 순서를 key 기반으로 계산
+ const seqOrder = Array.from(new Set(aplns.map((a) => a.seq)));
+ const keyOrder = seqOrder.map((seq) => {
+ return aplns.find((a) => a.seq === seq)!.id;
+ });
+
+ const oldIndex = keyOrder.indexOf(activeKey);
+ const newIndex = keyOrder.indexOf(overKey);
+
+ const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex);
+
+ // key → 새 seq 매핑
+ const keyToNewSeq = new Map<string, string>();
+ newKeyOrder.forEach((k, idx) => {
+ keyToNewSeq.set(k, idx.toString());
+ });
+
+ // aplns 재구성 + seq 재할당
+ const updatedAplns: FormData["aplns"] = [];
+ newKeyOrder.forEach((k) => {
+ const oldSeq = idToSeq.get(k)!;
+ const groupItems = aplns.filter((a) => a.seq === oldSeq);
+ groupItems.forEach((item) => {
+ updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! });
+ });
+ });
+
+ form.setValue("aplns", updatedAplns, { shouldValidate: false, shouldDirty: true });
+ };
+
+ const onSubmit = async (data: FormData) => {
+ setIsSubmitting(true);
+ setSubmitResult(null);
+
+ try {
+ // 세션 정보 확인
+ if (!session?.user) {
+ toast.error("로그인이 필요합니다.");
+ return;
+ }
+
+ const currentEmail = session.user.email ?? "";
+ const currentEpId = (session.user as { epId?: string }).epId;
+ const currentUserId = session.user.id ?? "";
+
+ debugLog("Current User", session.user);
+
+ if (!currentEpId) {
+ toast.error("사용자 정보가 올바르지 않습니다.");
+ return;
+ }
+
+ // 결재 경로 생성 (ID 제거하고 API 호출)
+ const approvalLines: ApprovalLine[] = await Promise.all(
+ data.aplns.map((apln) =>
+ createApprovalLine(
+ { epId: apln.epId },
+ apln.role,
+ apln.seq,
+ { opinion: apln.opinion },
+ ),
+ ),
+ );
+
+ debugLog("Approval Lines", approvalLines);
+
+ // 상신 요청 생성
+ const attachmentsArray = data.attachments
+ ? Array.from(data.attachments as FileList)
+ : undefined;
+
+ const submitRequest: SubmitApprovalRequest =
+ await createSubmitApprovalRequest(
+ data.contents,
+ data.subject,
+ approvalLines,
+ {
+ contentsType: data.contentsType,
+ docSecuType: data.docSecuType,
+ urgYn: data.urgYn ? "Y" : "N",
+ importantYn: data.importantYn ? "Y" : "N",
+ notifyOption: data.notifyOption,
+ docMngSaveCode: data.docMngSaveCode,
+ sbmLang: data.sbmLang,
+ timeZone: data.timeZone,
+ attachments: attachmentsArray,
+ },
+ );
- {/* 중요 여부 */}
- <FormField
- control={form.control}
- name="importantYn"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <FormLabel>중요</FormLabel>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- </div>
-
- {/* 첨부 파일 */}
- <FormField
- control={form.control}
- name="attachments"
- render={({ field }) => (
- <FormItem>
- <FormLabel>첨부 파일</FormLabel>
- <FormControl>
- <Input
- type="file"
- multiple
- onChange={(e) => field.onChange(e.target.files)}
- />
- </FormControl>
- <FormDescription>필요 시 파일을 선택하세요. (다중 선택 가능)</FormDescription>
- <FormMessage />
- </FormItem>
+ // API 호출 (보안 등급에 따라 분기)
+ const isSecure =
+ data.docSecuType === "CONFIDENTIAL" ||
+ data.docSecuType === "CONFIDENTIAL_STRICT";
+
+ debugLog("Submit Request", submitRequest);
+
+ const response = isSecure
+ ? await submitSecurityApproval(submitRequest)
+ : await submitApproval(submitRequest, {
+ userId: currentUserId,
+ epId: currentEpId,
+ emailAddress: currentEmail,
+ });
+
+ debugLog("Submit Response", response);
+
+ if (response.result === "SUCCESS") {
+ setSubmitResult({ apInfId: response.data.apInfId });
+ toast.success("결재가 성공적으로 상신되었습니다.");
+ onSubmitSuccess?.(response.data.apInfId);
+ form.reset();
+ } else {
+ toast.error(`결재 상신에 실패했습니다: ${response.result}`);
+ }
+ } catch (error) {
+ debugError("결재 상신 오류", error);
+ toast.error("결재 상신 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ <Card className="w-full max-w-5xl">
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 결재 상신
+ </CardTitle>
+ <CardDescription>새로운 결재를 상신합니다.</CardDescription>
+ </CardHeader>
+
+ <CardContent className="space-y-6">
+ {submitResult && (
+ <div className="p-4 bg-green-50 border border-green-200 rounded-lg">
+ <div className="flex items-center gap-2 text-green-700">
+ <AlertCircle className="w-4 h-4" />
+ <span className="font-medium">상신 완료</span>
+ </div>
+ <p className="text-sm text-green-600 mt-1">
+ 결재 ID: {submitResult.apInfId}
+ </p>
+ </div>
)}
- />
- </div>
-
- <Separator />
-
- {/* 제출 버튼 */}
- <div className="flex justify-end space-x-3">
- <Button
- type="button"
- variant="outline"
- onClick={() => form.reset()}
- disabled={isSubmitting}
- >
- 초기화
- </Button>
- <Button type="submit" disabled={isSubmitting}>
- {isSubmitting ? (
- <>
- <Loader2 className="w-4 h-4 mr-2 animate-spin" />
- 상신 중...
- </>
- ) : (
- '결재 상신'
- )}
- </Button>
- </div>
- </form>
- </Form>
- </CardContent>
- </Card>
- );
-} \ No newline at end of file
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="space-y-6"
+ >
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <Separator />
+
+ {/* 결재 경로 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">
+ 결재 경로
+ </h3>
+
+ {/* 상단 제어 버튼 */}
+ <div className="flex justify-end gap-2 mb-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={applyParallel}
+ >
+ 병렬
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={applyAfter}
+ >
+ 후결
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={ungroupParallel}
+ >
+ 해제
+ </Button>
+ </div>
+
+ {/* 결재자 추가 섹션 */}
+ <div className="p-4 border border-dashed border-gray-300 rounded-lg">
+ <div className="mb-2">
+ <label className="text-sm font-medium text-gray-700">
+ 결재자 추가
+ </label>
+ <p className="text-xs text-gray-500">
+ 사용자를 검색하여 결재 라인에
+ 추가하세요
+ </p>
+ </div>
+ <UserSelector
+ selectedUsers={[]}
+ onUsersChange={addApprovalUsers}
+ placeholder="결재자를 검색하세요..."
+ domainFilter={{
+ type: "exclude",
+ domains: ["partners"],
+ }}
+ maxSelections={10} // 최대 10명까지 추가 가능
+ />
+ </div>
+
+ {/* 그룹 기반 렌더링 */}
+ {aplns.length > 0 &&
+ (() => {
+ const groups = Object.values(
+ aplns.reduce<
+ Record<
+ string,
+ ApprovalLineItem[]
+ >
+ >((acc, apln) => {
+ acc[apln.seq] = acc[apln.seq]
+ ? [...acc[apln.seq], apln]
+ : [apln];
+ return acc;
+ }, {}),
+ ).sort(
+ (a, b) =>
+ parseInt(a[0].seq) -
+ parseInt(b[0].seq),
+ );
+
+ return (
+ <DndContext
+ sensors={sensors}
+ collisionDetection={
+ closestCenter
+ }
+ modifiers={[
+ restrictToVerticalAxis,
+ restrictToParentElement,
+ ]}
+ onDragEnd={handleDragEnd}
+ >
+ <SortableContext
+ items={groups.map(
+ (g) => g[0].id,
+ )}
+ strategy={
+ verticalListSortingStrategy
+ }
+ >
+ <div className="space-y-3">
+ {groups.map(
+ (group, idx) => (
+ <SortableApprovalGroup
+ key={
+ group[0]
+ .id
+ }
+ group={
+ group
+ }
+ index={idx}
+ form={form}
+ onRemoveGroup={() =>
+ removeApprovalGroup(
+ group[0]
+ .seq,
+ )
+ }
+ canRemove={
+ idx !==
+ 0 &&
+ aplns.length >
+ 1
+ }
+ selected={selectedSeqs.includes(
+ group[0]
+ .seq,
+ )}
+ onSelect={() =>
+ toggleSelectGroup(
+ group[0]
+ .seq,
+ )
+ }
+ />
+ ),
+ )}
+ </div>
+ </SortableContext>
+ </DndContext>
+ );
+ })()}
+
+ {aplns.length === 0 && (
+ <div className="text-center py-8 text-gray-500">
+ <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
+ <p>결재자를 추가해주세요</p>
+ </div>
+ )}
+ </div>
+
+ <FormField
+ control={form.control}
+ name="subject"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제목 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="결재 제목을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contents"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>내용 *</FormLabel>
+ <FormControl>
+ <RichTextEditor
+ value={field.value}
+ onChange={field.onChange}
+ height="400px"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */}
+ <div className="grid grid-cols-3 gap-4">
+ {/* 보안 등급 */}
+ <FormField
+ control={form.control}
+ name="docSecuType"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <FormLabel>보안</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={
+ field.onChange
+ }
+ defaultValue={field.value}
+ >
+ <SelectTrigger className="w-24">
+ <SelectValue placeholder="등급" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="PERSONAL">
+ 개인
+ </SelectItem>
+ <SelectItem value="CONFIDENTIAL">
+ 기밀
+ </SelectItem>
+ <SelectItem value="CONFIDENTIAL_STRICT">
+ 극기밀
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* 긴급 여부 */}
+ <FormField
+ control={form.control}
+ name="urgYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <FormLabel>긴급</FormLabel>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={
+ field.onChange
+ }
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* 중요 여부 */}
+ <FormField
+ control={form.control}
+ name="importantYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <FormLabel>중요</FormLabel>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={
+ field.onChange
+ }
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 첨부 파일 */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>첨부 파일</FormLabel>
+ <FormControl>
+ <Input
+ type="file"
+ multiple
+ onChange={(e) =>
+ field.onChange(
+ e.target.files,
+ )
+ }
+ />
+ </FormControl>
+ <FormDescription>
+ 필요 시 파일을 선택하세요. (다중
+ 선택 가능)
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 제출 버튼 */}
+ <div className="flex justify-end space-x-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => form.reset()}
+ disabled={isSubmitting}
+ >
+ 초기화
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ 상신 중...
+ </>
+ ) : (
+ "결재 상신"
+ )}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/components/layout/command-menu.tsx b/components/layout/command-menu.tsx
index 5537a042..d0c9c49a 100644
--- a/components/layout/command-menu.tsx
+++ b/components/layout/command-menu.tsx
@@ -1,12 +1,13 @@
"use client"
import * as React from "react"
-import { useRouter,usePathname } from "next/navigation"
+import { useRouter, usePathname, useParams } from "next/navigation"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Circle, File, Laptop, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { MenuSection, mainNav, additionalNav, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig";
+import { useTranslation } from "@/i18n/client"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
@@ -24,6 +25,9 @@ export function CommandMenu({ ...props }: DialogProps) {
const router = useRouter()
const [open, setOpen] = React.useState(false)
const { setTheme } = useTheme()
+ const params = useParams()
+ const lng = (params?.lng as string) || "evcp"
+ const { t } = useTranslation(lng, "menu")
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
@@ -81,20 +85,20 @@ const isPartnerRoute = pathname.includes("/partners");
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
- {main.map((group) => (
- <CommandGroup key={group.title} heading={group.title}>
+ {main.map((group: MenuSection) => (
+ <CommandGroup key={group.titleKey} heading={t(group.titleKey)}>
{group.items.map((navItem) => (
<CommandItem
- key={navItem.title}
- value={navItem.title}
+ key={`${navItem.titleKey}:${navItem.href}`}
+ value={t(navItem.titleKey)}
onSelect={() => {
- runCommand(() => router.push(navItem.href as string))
+ runCommand(() => router.push(`/${lng}${navItem.href}`))
}}
>
<div className="mr-2 flex h-4 w-4 items-center justify-center">
<Circle className="h-3 w-3" />
</div>
- {navItem.title}
+ {t(navItem.titleKey)}
</CommandItem>
))}
</CommandGroup>
@@ -104,14 +108,14 @@ const isPartnerRoute = pathname.includes("/partners");
// .filter((navitem) => !navitem.external)
.map((navItem) => (
<CommandItem
- key={navItem.title}
- value={navItem.title}
+ key={`${navItem.titleKey}:${navItem.href}`}
+ value={t(navItem.titleKey)}
onSelect={() => {
- runCommand(() => router.push(navItem.href as string))
+ runCommand(() => router.push(`/${lng}${navItem.href}`))
}}
>
<File />
- {navItem.title}
+ {t(navItem.titleKey)}
</CommandItem>
))}
</CommandGroup>
diff --git a/components/rich-text-editor/BlockquoteButton.tsx b/components/rich-text-editor/BlockquoteButton.tsx
new file mode 100644
index 00000000..be9a342b
--- /dev/null
+++ b/components/rich-text-editor/BlockquoteButton.tsx
@@ -0,0 +1,38 @@
+'use client'
+
+import React from 'react'
+import type { Editor } from '@tiptap/react'
+import { Toggle } from '@/components/ui/toggle'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { Quote as QuoteIcon } from 'lucide-react'
+
+interface BlockquoteButtonProps {
+ editor: Editor | null
+ disabled?: boolean
+ isActive: boolean
+ executeCommand: (command: () => void) => void
+}
+
+export function BlockquoteButton({ editor, disabled, isActive, executeCommand }: BlockquoteButtonProps) {
+ if (!editor) return null
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={isActive}
+ onMouseDown={e => e.preventDefault()}
+ onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBlockquote().run())}
+ disabled={disabled}
+ >
+ <QuoteIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>인용문</p>
+ </TooltipContent>
+ </Tooltip>
+ )
+}
+
+
diff --git a/components/rich-text-editor/BulletListButton.tsx b/components/rich-text-editor/BulletListButton.tsx
new file mode 100644
index 00000000..bf5b833c
--- /dev/null
+++ b/components/rich-text-editor/BulletListButton.tsx
@@ -0,0 +1,46 @@
+'use client'
+
+import React from 'react'
+import type { Editor } from '@tiptap/react'
+import { Toggle } from '@/components/ui/toggle'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { List as ListIcon } from 'lucide-react'
+
+interface BulletListButtonProps {
+ editor: Editor | null
+ disabled?: boolean
+ isActive: boolean
+ executeCommand: (command: () => void) => void
+}
+
+export function BulletListButton({ editor, disabled, isActive, executeCommand }: BulletListButtonProps) {
+
+
+ if (!editor) return null
+
+ const handleToggleBulletList = () => {
+ console.log('toggleBulletList')
+ executeCommand(() => editor.chain().focus().toggleBulletList().run())
+ }
+
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={isActive}
+ onMouseDown={e => e.preventDefault()}
+ onPressedChange={handleToggleBulletList}
+ disabled={disabled}
+ >
+ <ListIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>글머리 기호</p>
+ </TooltipContent>
+ </Tooltip>
+ )
+}
+
+
diff --git a/components/rich-text-editor/HistoryMenu.tsx b/components/rich-text-editor/HistoryMenu.tsx
new file mode 100644
index 00000000..e5bb819c
--- /dev/null
+++ b/components/rich-text-editor/HistoryMenu.tsx
@@ -0,0 +1,43 @@
+'use client'
+
+import React from 'react'
+import type { Editor } from '@tiptap/react'
+import { Toggle } from '@/components/ui/toggle'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { Undo, Redo } from 'lucide-react'
+
+interface HistoryMenuProps {
+ editor: Editor | null
+ disabled?: boolean
+ executeCommand: (command: () => void) => void
+}
+
+export function HistoryMenu({ editor, disabled, executeCommand }: HistoryMenuProps) {
+ if (!editor) return null
+ return (
+ <>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle size="sm" pressed={false} onPressedChange={() => executeCommand(() => editor.chain().focus().undo().run())} disabled={!editor.can().undo() || disabled}>
+ <Undo className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>실행 취소 (Ctrl+Z)</p>
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle size="sm" pressed={false} onPressedChange={() => executeCommand(() => editor.chain().focus().redo().run())} disabled={!editor.can().redo() || disabled}>
+ <Redo className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>다시 실행 (Ctrl+Y)</p>
+ </TooltipContent>
+ </Tooltip>
+ </>
+ )
+}
+
+
diff --git a/components/rich-text-editor/InlineStyleMenu.tsx b/components/rich-text-editor/InlineStyleMenu.tsx
new file mode 100644
index 00000000..02eac252
--- /dev/null
+++ b/components/rich-text-editor/InlineStyleMenu.tsx
@@ -0,0 +1,67 @@
+'use client'
+
+import React from 'react'
+import type { Editor } from '@tiptap/react'
+import { Toggle } from '@/components/ui/toggle'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { Bold, Italic, Underline as UnderlineIcon, Strikethrough } from 'lucide-react'
+
+interface InlineStyleMenuProps {
+ editor: Editor | null
+ disabled?: boolean
+ isBold: boolean
+ isItalic: boolean
+ isUnderline: boolean
+ isStrike: boolean
+ executeCommand: (command: () => void) => void
+}
+
+export function InlineStyleMenu({ editor, disabled, isBold, isItalic, isUnderline, isStrike, executeCommand }: InlineStyleMenuProps) {
+ if (!editor) return null
+ return (
+ <>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle size="sm" pressed={isBold} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBold().run())} disabled={disabled}>
+ <Bold className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>굵게 (Ctrl+B)</p>
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle size="sm" pressed={isItalic} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleItalic().run())} disabled={disabled}>
+ <Italic className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>기울임 (Ctrl+I)</p>
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle size="sm" pressed={isUnderline} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleUnderline().run())} disabled={disabled}>
+ <UnderlineIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>밑줄</p>
+ </TooltipContent>
+ </Tooltip>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle size="sm" pressed={isStrike} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleStrike().run())} disabled={disabled}>
+ <Strikethrough className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>취소선</p>
+ </TooltipContent>
+ </Tooltip>
+ </>
+ )
+}
+
+
diff --git a/components/rich-text-editor/OrderedListButton.tsx b/components/rich-text-editor/OrderedListButton.tsx
new file mode 100644
index 00000000..f4f68729
--- /dev/null
+++ b/components/rich-text-editor/OrderedListButton.tsx
@@ -0,0 +1,38 @@
+'use client'
+
+import React from 'react'
+import type { Editor } from '@tiptap/react'
+import { Toggle } from '@/components/ui/toggle'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
+import { ListOrdered as ListOrderedIcon } from 'lucide-react'
+
+interface OrderedListButtonProps {
+ editor: Editor | null
+ disabled?: boolean
+ isActive: boolean
+ executeCommand: (command: () => void) => void
+}
+
+export function OrderedListButton({ editor, disabled, isActive, executeCommand }: OrderedListButtonProps) {
+ if (!editor) return null
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Toggle
+ size="sm"
+ pressed={isActive}
+ onMouseDown={e => e.preventDefault()}
+ onPressedChange={() => executeCommand(() => editor.chain().focus().toggleOrderedList().run())}
+ disabled={disabled}
+ >
+ <ListOrderedIcon className="h-4 w-4" />
+ </Toggle>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>번호 매기기</p>
+ </TooltipContent>
+ </Tooltip>
+ )
+}
+
+
diff --git a/components/rich-text-editor/RichTextEditor.tsx b/components/rich-text-editor/RichTextEditor.tsx
index ceb76665..1360a5f8 100644
--- a/components/rich-text-editor/RichTextEditor.tsx
+++ b/components/rich-text-editor/RichTextEditor.tsx
@@ -1,17 +1,15 @@
'use client'
-import React, { useCallback, useRef, useState, useEffect } from 'react'
+import React, { useRef, useEffect } from 'react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
-import { Image as TiptapImage } from '@tiptap/extension-image'
-import Link from '@tiptap/extension-link'
import TextAlign from '@tiptap/extension-text-align'
import TextStyle from '@tiptap/extension-text-style'
import Subscript from '@tiptap/extension-subscript'
import Superscript from '@tiptap/extension-superscript'
-import { Extension } from '@tiptap/core'
+import Placeholder from '@tiptap/extension-placeholder'
import Highlight from '@tiptap/extension-highlight'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
@@ -23,95 +21,23 @@ import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
+import { toast } from 'sonner'
-// shadcn/ui & lucide
-import {
- Bold,
- Italic,
- Underline as UnderlineIcon,
- Strikethrough,
- ListOrdered,
- List,
- Quote,
- Undo,
- Redo,
- Link as LinkIcon,
- Image as ImageIcon,
- AlignLeft,
- AlignCenter,
- AlignRight,
- AlignJustify,
- Subscript as SubscriptIcon,
- Superscript as SuperscriptIcon,
- Table as TableIcon,
- Highlighter,
- CheckSquare,
- Type,
-} from 'lucide-react'
-import { Toggle } from '@/components/ui/toggle'
-import { Separator } from '@/components/ui/separator'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from '@/components/ui/dialog'
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from '@/components/ui/tooltip'
+import { FontSize } from './extensions/font-size'
+import ImageResize from 'tiptap-extension-resize-image'
+import { Toolbar } from './Toolbar'
-/* -------------------------------------------------------------------------------------------------
- * FontSize extension (wraps TextStyle)
- * -------------------------------------------------------------------------------------------------*/
-const FontSize = Extension.create({
- name: 'fontSize',
- addGlobalAttributes() {
- return [
- {
- types: ['textStyle'],
- attributes: {
- fontSize: {
- default: null,
- parseHTML: element => {
- const size = element.style.fontSize
- return size ? size.replace(/[^0-9]/g, '') : null
- },
- renderHTML: attributes => {
- if (!attributes.fontSize) return {}
- return {
- style: `font-size: ${attributes.fontSize}`,
- }
- },
- },
- },
- },
- ]
- },
-})
-
-/* -------------------------------------------------------------------------------------------------
- * Props & component
- * -------------------------------------------------------------------------------------------------*/
interface RichTextEditorProps {
value: string
onChange: (val: string) => void
disabled?: boolean
- height?: string // e.g. "400px" or "100%"
+ height?: string
+ className?: string
+ placeholder?: string
+ debounceMs?: number
+ onReady?: (editor: Editor) => void
+ onFocus?: () => void
+ onBlur?: () => void
}
export default function RichTextEditor({
@@ -119,51 +45,75 @@ export default function RichTextEditor({
onChange,
disabled,
height = '300px',
+ className,
+ placeholder,
+ debounceMs = 200,
+ onReady,
+ onFocus,
+ onBlur,
}: RichTextEditorProps) {
- // ---------------------------------------------------------------------------
- // Editor instance
- // ---------------------------------------------------------------------------
+ const updateTimerRef = useRef<number | undefined>(undefined)
+
+ const computedExtensions: unknown[] = [
+ StarterKit.configure({
+ bulletList: false,
+ orderedList: false,
+ listItem: false,
+ blockquote: false,
+ codeBlock: false,
+ code: false,
+ heading: { levels: [1, 2, 3] },
+ horizontalRule: false,
+ }),
+ Underline,
+ ImageResize,
+ TextAlign.configure({
+ types: ['heading', 'paragraph'],
+ alignments: ['left', 'center', 'right', 'justify'],
+ defaultAlignment: 'left',
+ }),
+ Subscript,
+ Superscript,
+ TextStyle,
+ FontSize,
+ Table.configure({ resizable: true }),
+ TableRow,
+ TableCell,
+ TableHeader,
+ Highlight.configure({ multicolor: true }),
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ BulletList.configure({
+ HTMLAttributes: {
+ class: 'list-disc ml-5',
+ },
+ }),
+ ListItem.configure({
+ HTMLAttributes: {
+ class: 'list-item my-0.5',
+ },
+ }),
+ OrderedList.configure({
+ HTMLAttributes: {
+ class: 'list-decimal ml-5',
+ },
+ }),
+ Blockquote.configure({
+ HTMLAttributes: {
+ class: 'border-l-4 pl-4 my-3 italic',
+ },
+ }),
+ ]
+
+ if (placeholder) {
+ computedExtensions.push(Placeholder.configure({ placeholder }))
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const extensionsForEditor = computedExtensions as any
+
const editor = useEditor({
- extensions: [
- StarterKit.configure({
- bulletList: false,
- orderedList: false,
- listItem: false,
- blockquote: false,
- codeBlock: false,
- code: false,
- heading: { levels: [1, 2, 3] },
- horizontalRule: false,
- }),
- Underline,
- TiptapImage.configure({
- HTMLAttributes: {
- class: 'max-w-full h-auto',
- style: 'max-width: 600px; height: auto;',
- },
- }),
- Link.configure({ openOnClick: true, linkOnPaste: true }),
- TextAlign.configure({
- types: ['heading', 'paragraph'],
- alignments: ['left', 'center', 'right', 'justify'],
- defaultAlignment: 'left',
- }),
- Subscript,
- Superscript,
- TextStyle,
- FontSize,
- Table.configure({ resizable: true }),
- TableRow,
- TableCell,
- TableHeader,
- Highlight.configure({ multicolor: true }),
- TaskList,
- TaskItem.configure({ nested: true }),
- BulletList,
- ListItem,
- OrderedList,
- Blockquote,
- ],
+ extensions: extensionsForEditor,
content: value,
editable: !disabled,
enablePasteRules: false,
@@ -172,7 +122,7 @@ export default function RichTextEditor({
editorProps: {
attributes: {
class:
- 'w-full h-full min-h-full bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 prose prose-sm max-w-none',
+ 'w-full h-full min-h-full bg-background px-3 py-2 text-sm leading-[1.6] ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 prose prose-sm max-w-none',
},
handleDrop: (view, event, slice, moved) => {
if (!moved && event.dataTransfer?.files.length) {
@@ -194,805 +144,83 @@ export default function RichTextEditor({
}
return false
},
+ handleDOMEvents: {
+ focus: () => {
+ onFocus?.()
+ return false
+ },
+ blur: () => {
+ onBlur?.()
+ return false
+ },
+ },
},
onUpdate: ({ editor }) => {
- onChange(editor.getHTML())
+ if (updateTimerRef.current) window.clearTimeout(updateTimerRef.current)
+ updateTimerRef.current = window.setTimeout(() => {
+ onChange(editor.getHTML())
+ }, debounceMs) as unknown as number
},
})
- // ---------------------------------------------------------------------------
- // Image handling (base64)
- // ---------------------------------------------------------------------------
+ useEffect(() => {
+ if (!editor) return
+ const current = editor.getHTML()
+ if (value !== current) {
+ editor.commands.setContent(value, false)
+ }
+ }, [editor, value])
+
+ useEffect(() => {
+ if (!editor) return
+ editor.setEditable(!disabled)
+ }, [editor, disabled])
+
+ const readyCalledRef = useRef(false)
+ useEffect(() => {
+ if (!editor || readyCalledRef.current) return
+ readyCalledRef.current = true
+ onReady?.(editor)
+ }, [editor, onReady])
+
+ const readFileAsDataURL = (file: File): Promise<string> =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = e => resolve(String(e.target?.result))
+ reader.onerror = reject
+ reader.readAsDataURL(file)
+ })
+
const handleImageUpload = async (file: File) => {
if (file.size > 3 * 1024 * 1024) {
- alert('이미지 크기는 3 MB 이하만 지원됩니다.')
+ toast.error('이미지 크기는 3 MB 이하만 지원됩니다.')
return
}
if (!file.type.startsWith('image/')) {
- alert('이미지 파일만 업로드 가능합니다.')
+ toast.error('이미지 파일만 업로드 가능합니다.')
return
}
- const reader = new FileReader()
- reader.onload = e => {
- const base64 = e.target?.result as string
- editor?.chain().focus().setImage({ src: base64, alt: file.name }).run()
+ try {
+ const dataUrl = await readFileAsDataURL(file)
+ editor?.chain().focus().setImage({ src: dataUrl, alt: file.name }).run()
+ } catch (error) {
+ console.error(error)
+ toast.error('이미지 읽기에 실패했습니다.')
}
- reader.onerror = () => alert('이미지 읽기에 실패했습니다.')
- reader.readAsDataURL(file)
}
- // ---------------------------------------------------------------------------
- // Toolbar (internal component)
- // ---------------------------------------------------------------------------
- const Toolbar: React.FC<{ editor: Editor | null; disabled?: boolean }> = ({
- editor,
- disabled,
- }) => {
- const [fontSize, setFontSize] = useState('16')
- const [isTableDialogOpen, setIsTableDialogOpen] = useState(false)
- const [tableRows, setTableRows] = useState('3')
- const [tableCols, setTableCols] = useState('3')
-
- // 간단한 툴바 상태 계산 - 실시간으로 계산하여 상태 동기화 문제 해결
- const getToolbarState = useCallback(() => {
- if (!editor) return {
- bold: false,
- italic: false,
- underline: false,
- strike: false,
- bulletList: false,
- orderedList: false,
- blockquote: false,
- link: false,
- highlight: false,
- taskList: false,
- table: false,
- subscript: false,
- superscript: false,
- heading: false,
- textAlign: 'left' as 'left' | 'center' | 'right' | 'justify',
- }
-
- const textAlign = editor.isActive({ textAlign: 'center' })
- ? 'center'
- : editor.isActive({ textAlign: 'right' })
- ? 'right'
- : editor.isActive({ textAlign: 'justify' })
- ? 'justify'
- : 'left'
-
- return {
- bold: editor.isActive('bold'),
- italic: editor.isActive('italic'),
- underline: editor.isActive('underline'),
- strike: editor.isActive('strike'),
- bulletList: editor.isActive('bulletList'),
- orderedList: editor.isActive('orderedList'),
- blockquote: editor.isActive('blockquote'),
- link: editor.isActive('link'),
- highlight: editor.isActive('highlight'),
- taskList: editor.isActive('taskList'),
- table: editor.isActive('table'),
- subscript: editor.isActive('subscript'),
- superscript: editor.isActive('superscript'),
- heading: [1, 2, 3, 4, 5, 6].some(l => editor.isActive('heading', { level: l })),
- textAlign: textAlign as 'left' | 'center' | 'right' | 'justify',
- }
- }, [editor])
-
- const toolbarState = getToolbarState()
-
- // 폰트 사이즈 업데이트 - 복잡한 timeout 로직 제거
- useEffect(() => {
- if (!editor) return
-
- const updateFontSize = () => {
- const currentFontSizeAttr = editor.getAttributes('textStyle').fontSize
- if (typeof currentFontSizeAttr === 'string') {
- const sizeValue = currentFontSizeAttr.replace('px', '')
- setFontSize(sizeValue)
- } else {
- setFontSize('16')
- }
- }
-
- updateFontSize()
- editor.on('selectionUpdate', updateFontSize)
- editor.on('transaction', updateFontSize)
-
- return () => {
- editor.off('selectionUpdate', updateFontSize)
- editor.off('transaction', updateFontSize)
- }
- }, [editor])
-
- // 개선된 executeCommand - 포커스 문제 해결 및 단순화
- const executeCommand = useCallback(
- (command: () => void) => {
- if (!editor || disabled) return
-
- // 명령 실행 전 포커스 확보
- if (!editor.isFocused) {
- editor.commands.focus()
- }
-
- // 명령 실행
- command()
-
- // 명령 실행 후 포커스 유지
- setTimeout(() => {
- if (editor && !editor.isFocused) {
- editor.commands.focus()
- }
- }, 10)
- },
- [editor, disabled]
- )
-
- // 폰트 사이즈 입력 필드의 동적 width 계산
- const getFontSizeInputWidth = useCallback((size: string) => {
- const length = size.length
- return Math.max(length * 8 + 16, 40) // 최소 40px, 글자 수에 따라 증가
- }, [])
-
- if (!editor) return null
-
- // --- Render toolbar UI ---
- return (
- <TooltipProvider>
- <div className="border border-input bg-transparent rounded-t-md">
- <div className="flex flex-wrap gap-1 p-1">
- {/* 텍스트 스타일 */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.bold}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleBold().run())
- }
- disabled={disabled}
- >
- <Bold className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>굵게 (Ctrl+B)</p>
- </TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.italic}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleItalic().run())
- }
- disabled={disabled}
- >
- <Italic className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>기울임 (Ctrl+I)</p>
- </TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.underline}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleUnderline().run())
- }
- disabled={disabled}
- >
- <UnderlineIcon className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>밑줄</p>
- </TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.strike}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleStrike().run())
- }
- disabled={disabled}
- >
- <Strikethrough className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>취소선</p>
- </TooltipContent>
- </Tooltip>
-
- <Separator orientation="vertical" className="h-6" />
-
- {/* 제목 및 단락 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Toggle size="sm" pressed={toolbarState.heading} disabled={disabled}>
- <Type className="h-4 w-4" />
- </Toggle>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="start">
- {([1, 2, 3] as Array<1 | 2 | 3>).map((level) => (
- <DropdownMenuItem
- key={level}
- onClick={() =>
- executeCommand(() =>
- editor.chain().focus().toggleHeading({ level }).run()
- )
- }
- className="flex items-center"
- >
- <span
- className={`font-bold ${level === 1 ? 'text-xl' : level === 2 ? 'text-lg' : 'text-base'
- }`}
- >
- 제목 {level}
- </span>
- </DropdownMenuItem>
- ))}
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().setParagraph().run())
- }
- className="flex items-center"
- >
- <span>본문</span>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
-
- {/* 글자 크기 - 동적 width 적용 */}
- <div className="flex items-center space-x-1">
- <Input
- type="number"
- min="8"
- max="72"
- value={fontSize}
- onChange={(e) => {
- const size = e.target.value
- setFontSize(size)
- if (size && parseInt(size) >= 8 && parseInt(size) <= 72) {
- executeCommand(() =>
- editor
- .chain()
- .focus()
- .setMark('textStyle', { fontSize: `${size}px` })
- .run()
- )
- }
- }}
- style={{ width: `${getFontSizeInputWidth(fontSize)}px` }}
- className="h-8 text-xs"
- disabled={disabled}
- />
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}>
- <Type className="h-3 w-3" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="start">
- {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map((size) => (
- <DropdownMenuItem
- key={size}
- onClick={() => {
- setFontSize(size.toString())
- executeCommand(() =>
- editor
- .chain()
- .focus()
- .setMark('textStyle', { fontSize: `${size}px` })
- .run()
- )
- }}
- className="flex items-center"
- >
- <span style={{ fontSize: `${Math.min(size, 16)}px` }}>{size}px</span>
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
-
- <Separator orientation="vertical" className="h-6" />
-
- {/* 리스트 */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.bulletList}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleBulletList().run())
- }
- disabled={disabled}
- >
- <List className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>글머리 기호</p>
- </TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.orderedList}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleOrderedList().run())
- }
- disabled={disabled}
- >
- <ListOrdered className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>번호 매기기</p>
- </TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.blockquote}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleBlockquote().run())
- }
- disabled={disabled}
- >
- <Quote className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>인용문</p>
- </TooltipContent>
- </Tooltip>
-
- <Separator orientation="vertical" className="h-6" />
-
- {/* 텍스트 정렬 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle size="sm" pressed={toolbarState.textAlign !== 'left'} disabled={disabled}>
- {toolbarState.textAlign === 'center' ? (
- <AlignCenter className="h-4 w-4" />
- ) : toolbarState.textAlign === 'right' ? (
- <AlignRight className="h-4 w-4" />
- ) : toolbarState.textAlign === 'justify' ? (
- <AlignJustify className="h-4 w-4" />
- ) : (
- <AlignLeft className="h-4 w-4" />
- )}
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>텍스트 정렬</p>
- </TooltipContent>
- </Tooltip>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="start">
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().setTextAlign('left').run())
- }
- className="flex items-center"
- >
- <AlignLeft className="mr-2 h-4 w-4" />
- <span>왼쪽 정렬</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().setTextAlign('center').run())
- }
- className="flex items-center"
- >
- <AlignCenter className="mr-2 h-4 w-4" />
- <span>가운데 정렬</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().setTextAlign('right').run())
- }
- className="flex items-center"
- >
- <AlignRight className="mr-2 h-4 w-4" />
- <span>오른쪽 정렬</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().setTextAlign('justify').run())
- }
- className="flex items-center"
- >
- <AlignJustify className="mr-2 h-4 w-4" />
- <span>양쪽 정렬</span>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
-
- <Separator orientation="vertical" className="h-6" />
-
- {/* 링크 */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.link}
- onPressedChange={() => {
- if (toolbarState.link) {
- executeCommand(() => editor.chain().focus().unsetLink().run())
- } else {
- const url = window.prompt('URL을 입력하세요:')
- if (url) {
- executeCommand(() => editor.chain().focus().setLink({ href: url }).run())
- }
- }
- }}
- disabled={disabled}
- >
- <LinkIcon className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>링크 {toolbarState.link ? '제거' : '삽입'}</p>
- </TooltipContent>
- </Tooltip>
-
- {/* 이미지 업로드 */}
- <Tooltip>
- <TooltipTrigger asChild>
- <div className="relative">
- <input
- type="file"
- accept="image/*"
- className="hidden"
- id="image-upload-rt"
- onChange={(e) => {
- const file = e.target.files?.[0]
- if (file) handleImageUpload(file)
- }}
- />
- <Toggle
- size="sm"
- pressed={false}
- onPressedChange={() => {
- document.getElementById('image-upload-rt')?.click()
- }}
- disabled={disabled}
- >
- <ImageIcon className="h-4 w-4" />
- </Toggle>
- </div>
- </TooltipTrigger>
- <TooltipContent>
- <p>이미지 삽입</p>
- </TooltipContent>
- </Tooltip>
-
- <Separator orientation="vertical" className="h-6" />
-
- {/* 첨자 */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.subscript}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleSubscript().run())
- }
- disabled={disabled}
- >
- <SubscriptIcon className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>아래 첨자</p>
- </TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.superscript}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleSuperscript().run())
- }
- disabled={disabled}
- >
- <SuperscriptIcon className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>위 첨자</p>
- </TooltipContent>
- </Tooltip>
-
- <Separator orientation="vertical" className="h-6" />
-
- {/* 하이라이트 */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.highlight}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleHighlight().run())
- }
- disabled={disabled}
- >
- <Highlighter className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>하이라이트</p>
- </TooltipContent>
- </Tooltip>
-
- {/* 체크리스트 */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={toolbarState.taskList}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().toggleTaskList().run())
- }
- disabled={disabled}
- >
- <CheckSquare className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>체크리스트</p>
- </TooltipContent>
- </Tooltip>
-
- <Separator orientation="vertical" className="h-6" />
-
- {/* 테이블 */}
- {!toolbarState.table ? (
- <Dialog open={isTableDialogOpen} onOpenChange={setIsTableDialogOpen}>
- <DialogTrigger asChild>
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={false}
- onPressedChange={() => {
- if (editor && editor.isActive('table')) {
- alert('커서를 테이블 밖으로 이동시키세요')
- return
- }
- setIsTableDialogOpen(true)
- }}
- disabled={disabled}
- >
- <TableIcon className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>테이블 삽입</p>
- </TooltipContent>
- </Tooltip>
- </DialogTrigger>
- <DialogContent className="sm:max-w-md">
- <DialogHeader>
- <DialogTitle>테이블 크기 설정</DialogTitle>
- <DialogDescription>
- 생성할 테이블의 행과 열 수를 입력하세요 (1-20)
- </DialogDescription>
- </DialogHeader>
- <div className="grid grid-cols-2 gap-4 py-4">
- <div className="space-y-2">
- <Label htmlFor="table-rows">행 수</Label>
- <Input
- id="table-rows"
- type="number"
- min="1"
- max="20"
- value={tableRows}
- onChange={(e) => setTableRows(e.target.value)}
- placeholder="3"
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="table-cols">열 수</Label>
- <Input
- id="table-cols"
- type="number"
- min="1"
- max="20"
- value={tableCols}
- onChange={(e) => setTableCols(e.target.value)}
- placeholder="3"
- />
- </div>
- </div>
- <DialogFooter>
- <Button variant="outline" onClick={() => setIsTableDialogOpen(false)}>
- 취소
- </Button>
- <Button
- onClick={() => {
- const rows = parseInt(tableRows, 10)
- const cols = parseInt(tableCols, 10)
- if (rows >= 1 && rows <= 20 && cols >= 1 && cols <= 20) {
- executeCommand(() =>
- editor.chain().focus().insertTable({ rows, cols }).run()
- )
- setIsTableDialogOpen(false)
- }
- }}
- disabled={
- !tableRows ||
- !tableCols ||
- parseInt(tableRows, 10) < 1 ||
- parseInt(tableRows, 10) > 20 ||
- parseInt(tableCols, 10) < 1 ||
- parseInt(tableCols, 10) > 20
- }
- >
- 생성
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- ) : (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle size="sm" pressed={true} disabled={disabled}>
- <TableIcon className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>테이블 편집</p>
- </TooltipContent>
- </Tooltip>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="start">
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().addRowBefore().run())
- }
- className="flex items-center"
- >
- <span>위에 행 추가</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().addRowAfter().run())
- }
- className="flex items-center"
- >
- <span>아래에 행 추가</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().addColumnBefore().run())
- }
- className="flex items-center"
- >
- <span>왼쪽에 열 추가</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().addColumnAfter().run())
- }
- className="flex items-center"
- >
- <span>오른쪽에 열 추가</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().deleteRow().run())
- }
- className="flex items-center"
- >
- <span>행 삭제</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().deleteColumn().run())
- }
- className="flex items-center"
- >
- <span>열 삭제</span>
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() =>
- executeCommand(() => editor.chain().focus().deleteTable().run())
- }
- className="flex items-center text-red-600"
- >
- <span>테이블 삭제</span>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )}
-
- <Separator orientation="vertical" className="h-6" />
-
- {/* 실행 취소/다시 실행 */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={false}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().undo().run())
- }
- disabled={!editor.can().undo() || disabled}
- >
- <Undo className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>실행 취소 (Ctrl+Z)</p>
- </TooltipContent>
- </Tooltip>
-
- <Tooltip>
- <TooltipTrigger asChild>
- <Toggle
- size="sm"
- pressed={false}
- onPressedChange={() =>
- executeCommand(() => editor.chain().focus().redo().run())
- }
- disabled={!editor.can().redo() || disabled}
- >
- <Redo className="h-4 w-4" />
- </Toggle>
- </TooltipTrigger>
- <TooltipContent>
- <p>다시 실행 (Ctrl+Y)</p>
- </TooltipContent>
- </Tooltip>
- </div>
- </div>
- </TooltipProvider>
- )
- }
-
- // ---------------------------------------------------------------------------
- // Layout & rendering
- // ---------------------------------------------------------------------------
- const containerStyle = height === '100%' ? { height: '100%' } : { height }
- const editorContentStyle =
- height === '100%' ? { flex: 1, minHeight: 0 } : { height: `calc(${height} - 60px)` }
+ const containerStyle = { height }
return (
- <div
- className={`border rounded-md bg-background ${height === '100%' ? 'flex flex-col h-full' : ''}`}
- style={containerStyle}
- >
- <div className="flex-shrink-0 border-b">
- <Toolbar editor={editor} disabled={disabled} />
+ <div className={`border rounded-md bg-background flex flex-col ${className ?? ''}`} style={containerStyle}>
+ <div className="flex-none border-b">
+ <Toolbar editor={editor} disabled={disabled} onSelectImageFile={handleImageUpload} />
</div>
- <div className="overflow-y-auto" style={editorContentStyle}>
+ <div className="flex-1 min-h-0 overflow-y-auto">
<EditorContent editor={editor} className="h-full" />
</div>
</div>
)
-} \ No newline at end of file
+}
+
+
diff --git a/components/rich-text-editor/StyleMenu.tsx b/components/rich-text-editor/StyleMenu.tsx
new file mode 100644
index 00000000..a919e639
--- /dev/null
+++ b/components/rich-text-editor/StyleMenu.tsx
@@ -0,0 +1,65 @@
+'use client'
+
+import React from 'react'
+import type { Editor } from '@tiptap/react'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+
+interface StyleMenuProps {
+ editor: Editor | null
+ disabled?: boolean
+ executeCommand: (command: () => void) => void
+}
+
+export function StyleMenu({ editor, disabled, executeCommand }: StyleMenuProps) {
+ if (!editor) return null
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild onMouseDown={e => e.preventDefault()}>
+ <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}>
+ 스타일
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ <DropdownMenuItem
+ className="flex items-center"
+ onSelect={() =>
+ executeCommand(() =>
+ editor
+ .chain()
+ .focus()
+ .setMark('textStyle', { fontSize: '32px' })
+ .setBold()
+ .run()
+ )
+ }
+ >
+ 제목 (굵게 + 32pt)
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ className="flex items-center"
+ onSelect={() =>
+ executeCommand(() =>
+ editor
+ .chain()
+ .focus()
+ .unsetBold()
+ .setParagraph()
+ .setMark('textStyle', { fontSize: null as unknown as string })
+ .run()
+ )
+ }
+ >
+ 본문 (기본)
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+}
+
+
diff --git a/components/rich-text-editor/TextAlignMenu.tsx b/components/rich-text-editor/TextAlignMenu.tsx
new file mode 100644
index 00000000..98cc0d4c
--- /dev/null
+++ b/components/rich-text-editor/TextAlignMenu.tsx
@@ -0,0 +1,46 @@
+'use client'
+
+import React from 'react'
+import type { Editor } from '@tiptap/react'
+import { Button } from '@/components/ui/button'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
+import { AlignCenter, AlignJustify, AlignLeft, AlignRight } from 'lucide-react'
+
+interface TextAlignMenuProps {
+ editor: Editor
+ disabled?: boolean
+ currentAlign?: 'left' | 'center' | 'right' | 'justify'
+ executeCommand: (command: () => void) => void
+}
+
+export function TextAlignMenu({ editor, disabled, executeCommand }: TextAlignMenuProps) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild onMouseDown={e => e.preventDefault()}>
+ <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}>
+ 정렬
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('left').run())} className="flex items-center">
+ <AlignLeft className="mr-2 h-4 w-4" />
+ <span>왼쪽 정렬</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('center').run())} className="flex items-center">
+ <AlignCenter className="mr-2 h-4 w-4" />
+ <span>가운데 정렬</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('right').run())} className="flex items-center">
+ <AlignRight className="mr-2 h-4 w-4" />
+ <span>오른쪽 정렬</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('justify').run())} className="flex items-center">
+ <AlignJustify className="mr-2 h-4 w-4" />
+ <span>양쪽 정렬</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+}
+
+
diff --git a/components/rich-text-editor/Toolbar.tsx b/components/rich-text-editor/Toolbar.tsx
new file mode 100644
index 00000000..13e31c24
--- /dev/null
+++ b/components/rich-text-editor/Toolbar.tsx
@@ -0,0 +1,350 @@
+'use client'
+
+import React, { useCallback, useEffect, useId, useReducer, useState } from 'react'
+import type { Editor } from '@tiptap/react'
+
+import { Image as ImageIcon, Type } from 'lucide-react'
+
+import { Toggle } from '@/components/ui/toggle'
+import { Separator } from '@/components/ui/separator'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+import { TextAlignMenu } from './TextAlignMenu'
+import { StyleMenu } from './StyleMenu'
+import { InlineStyleMenu } from './InlineStyleMenu'
+import { BulletListButton } from './BulletListButton'
+import { OrderedListButton } from './OrderedListButton'
+import { BlockquoteButton } from './BlockquoteButton'
+import { HistoryMenu } from './HistoryMenu'
+
+interface ToolbarProps {
+ editor: Editor | null
+ disabled?: boolean
+ onSelectImageFile?: (file: File) => void
+}
+
+export function Toolbar({ editor, disabled, onSelectImageFile }: ToolbarProps) {
+ const [fontSize, setFontSize] = useState('16')
+ const [imageWidthPct, setImageWidthPct] = useState<string>('100')
+
+ const imageInputId = useId()
+
+ const getToolbarState = useCallback(() => {
+ if (!editor)
+ return {
+ bold: false,
+ italic: false,
+ underline: false,
+ strike: false,
+ bulletList: false,
+ orderedList: false,
+ blockquote: false,
+ highlight: false,
+ taskList: false,
+ table: false,
+ subscript: false,
+ superscript: false,
+ heading: false,
+ textAlign: 'left' as 'left' | 'center' | 'right' | 'justify',
+ }
+
+ const textAlign = editor.isActive({ textAlign: 'center' })
+ ? 'center'
+ : editor.isActive({ textAlign: 'right' })
+ ? 'right'
+ : editor.isActive({ textAlign: 'justify' })
+ ? 'justify'
+ : 'left'
+
+ return {
+ bold: editor.isActive('bold'),
+ italic: editor.isActive('italic'),
+ underline: editor.isActive('underline'),
+ strike: editor.isActive('strike'),
+ bulletList: editor.isActive('bulletList'),
+ orderedList: editor.isActive('orderedList'),
+ blockquote: editor.isActive('blockquote'),
+ highlight: editor.isActive('highlight'),
+ taskList: editor.isActive('taskList'),
+ table: editor.isActive('table'),
+ subscript: editor.isActive('subscript'),
+ superscript: editor.isActive('superscript'),
+ heading: [1, 2, 3, 4, 5, 6].some(l => editor.isActive('heading', { level: l })),
+ textAlign: textAlign as 'left' | 'center' | 'right' | 'justify',
+ }
+ }, [editor])
+
+ const toolbarState = getToolbarState()
+
+ useEffect(() => {
+ if (!editor) return
+ const updateFontSize = () => {
+ const currentFontSizeAttr = editor.getAttributes('textStyle').fontSize
+ if (typeof currentFontSizeAttr === 'string') {
+ const sizeValue = currentFontSizeAttr.replace('px', '')
+ setFontSize(sizeValue)
+ } else {
+ try {
+ const from = editor.state.selection.from
+ const dom = editor.view.domAtPos(from).node as HTMLElement
+ const el = dom.nodeType === 3 ? (dom.parentElement as HTMLElement) : (dom as HTMLElement)
+ const computed = window.getComputedStyle(el)
+ const val = computed.fontSize.replace('px', '')
+ setFontSize(val || '16')
+ } catch {
+ setFontSize('16')
+ }
+ }
+ }
+ updateFontSize()
+ editor.on('selectionUpdate', updateFontSize)
+ editor.on('transaction', updateFontSize)
+ return () => {
+ editor.off('selectionUpdate', updateFontSize)
+ editor.off('transaction', updateFontSize)
+ }
+ }, [editor])
+
+ const [, forceRender] = useReducer((x: number) => x + 1, 0)
+ const executeCommand = useCallback(
+ (command: () => void) => {
+ if (!editor || disabled) return
+ if (!editor.isFocused) editor.commands.focus()
+ command()
+ forceRender()
+ setTimeout(() => {
+ if (editor && !editor.isFocused) editor.commands.focus()
+ forceRender()
+ }, 0)
+ },
+ [editor, disabled]
+ )
+
+ useEffect(() => {
+ if (!editor) return
+ const updateImageWidth = () => {
+ if (editor.isActive('image')) {
+ const width = editor.getAttributes('image').width as string | undefined
+ if (typeof width === 'string') {
+ const pct = width.endsWith('%') ? width.replace('%', '') : width.replace('px', '')
+ setImageWidthPct(pct)
+ } else {
+ setImageWidthPct('100')
+ }
+ }
+ }
+ updateImageWidth()
+ editor.on('selectionUpdate', updateImageWidth)
+ editor.on('transaction', updateImageWidth)
+ return () => {
+ editor.off('selectionUpdate', updateImageWidth)
+ editor.off('transaction', updateImageWidth)
+ }
+ }, [editor])
+
+ if (!editor) return null
+
+ return (
+ <TooltipProvider>
+ <div className="border border-input bg-transparent rounded-t-md">
+ <div className="flex flex-wrap gap-1 p-1">
+ <StyleMenu editor={editor} disabled={disabled} executeCommand={executeCommand} />
+
+ <InlineStyleMenu
+ editor={editor}
+ disabled={disabled}
+ isBold={toolbarState.bold}
+ isItalic={toolbarState.italic}
+ isUnderline={toolbarState.underline}
+ isStrike={toolbarState.strike}
+ executeCommand={executeCommand}
+ />
+
+ <Separator orientation="vertical" className="h-6" />
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Toggle size="sm" pressed={toolbarState.heading} disabled={disabled}>
+ <Type className="h-4 w-4" />
+ </Toggle>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ {([1, 2, 3] as Array<1 | 2 | 3>).map(level => (
+ <DropdownMenuItem
+ key={level}
+ onSelect={() => executeCommand(() => editor.chain().focus().toggleHeading({ level }).run())}
+ className="flex items-center"
+ >
+ <span className={`font-bold ${level === 1 ? 'text-xl' : level === 2 ? 'text-lg' : 'text-base'}`}>
+ 제목 {level}
+ </span>
+ </DropdownMenuItem>
+ ))}
+ <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setParagraph().run())} className="flex items-center">
+ <span>본문</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <div className="flex items-center gap-1">
+ <Input
+ type="number"
+ min="8"
+ max="72"
+ value={fontSize}
+ onChange={e => {
+ const size = e.target.value
+ setFontSize(size)
+ if (size && parseInt(size) >= 8 && parseInt(size) <= 72) {
+ executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run())
+ }
+ }}
+ style={{ width: `64px` }}
+ className="h-8 text-xs text-right"
+ disabled={disabled}
+ />
+ <div className="flex items-center gap-1">
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onMouseDown={e => e.preventDefault()}
+ onClick={() => {
+ const next = Math.max(8, Math.min(72, parseInt(fontSize || '16', 10) - 1))
+ setFontSize(String(next))
+ executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${next}px` }).run())
+ }}
+ disabled={disabled}
+ >
+ -
+ </Button>
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ className="h-8 w-8 p-0"
+ onMouseDown={e => e.preventDefault()}
+ onClick={() => {
+ const next = Math.max(8, Math.min(72, parseInt(fontSize || '16', 10) + 1))
+ setFontSize(String(next))
+ executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${next}px` }).run())
+ }}
+ disabled={disabled}
+ >
+ +
+ </Button>
+ </div>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}>
+ <Type className="h-3 w-3" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map(size => (
+ <DropdownMenuItem
+ key={size}
+ onSelect={() => {
+ setFontSize(size.toString())
+ executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run())
+ }}
+ className="flex items-center"
+ >
+ <span style={{ fontSize: `${Math.min(size, 16)}px` }}>{size}px</span>
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ <div className="flex items-center gap-1">
+ <BulletListButton editor={editor} disabled={disabled} isActive={toolbarState.bulletList} executeCommand={executeCommand} />
+ <OrderedListButton editor={editor} disabled={disabled} isActive={toolbarState.orderedList} executeCommand={executeCommand} />
+ <BlockquoteButton editor={editor} disabled={disabled} isActive={toolbarState.blockquote} executeCommand={executeCommand} />
+ </div>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ <TextAlignMenu editor={editor} disabled={disabled} executeCommand={executeCommand} />
+
+ <Separator orientation="vertical" className="h-6" />
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="relative">
+ <input
+ type="file"
+ accept="image/*"
+ className="hidden"
+ id={`image-upload-rt-${imageInputId}`}
+ onChange={e => {
+ const file = e.target.files?.[0]
+ if (file) onSelectImageFile?.(file)
+ }}
+ />
+ <Toggle
+ size="sm"
+ pressed={false}
+ onPressedChange={() => {
+ const el = document.getElementById(`image-upload-rt-${imageInputId}`) as HTMLInputElement | null
+ el?.click()
+ }}
+ disabled={disabled}
+ aria-label="이미지 삽입"
+ >
+ <ImageIcon className="h-4 w-4" />
+ </Toggle>
+ </div>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>이미지 삽입</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {editor.isActive('image') && (
+ <div className="flex items-center gap-1 ml-1">
+ <Label className="text-xs">너비</Label>
+ <Input
+ type="number"
+ min={5}
+ max={100}
+ value={imageWidthPct}
+ onChange={e => {
+ const val = e.target.value
+ setImageWidthPct(val)
+ const pct = Math.min(100, Math.max(5, parseInt(val || '0', 10)))
+ executeCommand(() => editor.chain().focus().updateAttributes('image', { width: `${pct}%` }).run())
+ }}
+ className="h-8 w-16 text-xs"
+ disabled={disabled}
+ />
+ <span className="text-xs">%</span>
+ </div>
+ )}
+
+ <Separator orientation="vertical" className="h-6" />
+
+ <HistoryMenu editor={editor} disabled={disabled} executeCommand={executeCommand} />
+ </div>
+ </div>
+ </TooltipProvider>
+ )
+}
+
+
diff --git a/components/rich-text-editor/extensions/font-size.ts b/components/rich-text-editor/extensions/font-size.ts
new file mode 100644
index 00000000..1b7e2700
--- /dev/null
+++ b/components/rich-text-editor/extensions/font-size.ts
@@ -0,0 +1,31 @@
+import { Extension } from '@tiptap/core'
+
+export const FontSize = Extension.create({
+ name: 'fontSize',
+ addGlobalAttributes() {
+ return [
+ {
+ types: ['textStyle'],
+ attributes: {
+ fontSize: {
+ default: null,
+ parseHTML: element => {
+ const sizeWithUnit = (element as HTMLElement).style.fontSize
+ return sizeWithUnit || null
+ },
+ renderHTML: attributes => {
+ if (!attributes.fontSize) return {}
+ const value = String(attributes.fontSize)
+ const withUnit = /(px|em|rem|%)$/i.test(value) ? value : `${value}px`
+ return {
+ style: `font-size: ${withUnit}`,
+ }
+ },
+ },
+ },
+ },
+ ]
+ },
+})
+
+
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index 6cd4ae96..dd10e895 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -367,18 +367,23 @@ export const mainNav: MenuSection[] = [
groupKey: "groups.interface"
},
{
+ titleKey: "menu.information_system.approval_template",
+ href: "/evcp/approval/template",
+ groupKey: "groups.approval"
+ },
+ {
titleKey: "menu.information_system.approval_log",
- href: "/evcp/approval-log",
+ href: "/evcp/approval/log",
groupKey: "groups.approval"
},
{
- titleKey: "menu.information_system.approval_path",
- href: "/evcp/approval-path",
+ titleKey: "menu.information_system.approval_line",
+ href: "/evcp/approval/line",
groupKey: "groups.approval"
},
{
titleKey: "menu.information_system.approval_after",
- href: "/evcp/approval-after",
+ href: "/evcp/approval/after",
groupKey: "groups.approval"
},
{
diff --git a/db/schema/knox/approvals.ts b/db/schema/knox/approvals.ts
index 27332ed6..0f8ee90a 100644
--- a/db/schema/knox/approvals.ts
+++ b/db/schema/knox/approvals.ts
@@ -1,7 +1,36 @@
-import { boolean, jsonb, text, timestamp, } from "drizzle-orm/pg-core";
+import { boolean, jsonb, text, timestamp, integer, uuid } from "drizzle-orm/pg-core";
import { knoxSchema } from "./employee";
+import { users } from '@/db/schema/users';
-export const approval = knoxSchema.table("approval", {
+/**
+ * 결재 관련 테이블 정의
+ *
+ * 템플릿 관리
+ * 결재선 관리
+ * 결재 로그 관리 (히스토리 관리)
+ *
+ */
+
+// 결재 템플릿 히스토리
+export const approvalTemplateHistory = knoxSchema.table('approval_template_history', {
+ id: uuid().primaryKey().defaultRandom(), // 히스토리 아이디 UUID
+ templateId: uuid()
+ .references(() => approvalTemplates.id, { onDelete: 'cascade' })
+ .notNull(),
+ version: integer().notNull(), // 히스토리 버전
+ subject: text().notNull(), // 템플릿 제목
+ content: text().notNull(), // 템플릿 내용
+ changeDescription: text(), // 변경 사항 설명
+ changedBy: integer() // 변경자 - eVCP 유저 아이디 기반 참조
+ .notNull()
+ .references(() => users.id, { onDelete: 'set null' }),
+ createdAt: timestamp().defaultNow().notNull(),
+ // 히스토리는 업데이트 없음. 생성 시점만 기록.
+});
+
+
+// 실제 결재 상신 로그
+export const approvalLogs = knoxSchema.table("approval_logs", {
apInfId: text("ap_inf_id").primaryKey(),
userId: text("user_id").notNull(),
epId: text("ep_id").notNull(),
@@ -9,8 +38,69 @@ export const approval = knoxSchema.table("approval", {
subject: text("subject").notNull(),
content: text("content").notNull(),
status: text("status").notNull(),
- aplns: jsonb("aplns").notNull(),
+ aplns: jsonb("aplns").notNull(), // approval lines = 결재선
isDeleted: boolean("is_deleted").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
-}); \ No newline at end of file
+});
+
+
+// 결재 템플릿
+export const approvalTemplates = knoxSchema.table('approval_templates', {
+ id: uuid().primaryKey().defaultRandom(), // 템플릿 아이디 UUID
+ name: text().notNull(), // 템플릿 이름
+ // slug: text('slug').notNull().unique(), // 템플릿 슬러그는 UUID로 대체하기
+ subject: text().notNull(), // 템플릿 제목
+ content: text().notNull(), // 템플릿 내용
+ description: text(), // 템플릿 설명
+ category: text(), // 템플릿 카테고리 설명
+
+ // 선택된 결재선 참조 (nullable, 결재선은 별도에서 관리)
+ approvalLineId: uuid().references(() => approvalLines.id, { onDelete: 'set null' }),
+
+ // 메타데이터
+ createdBy: integer() // 템플릿 생성자 - eVCP 유저 아이디 기반 참조
+ .notNull()
+ .references(() => users.id, { onDelete: 'set null' }),
+ createdAt: timestamp().defaultNow().notNull(),
+ updatedAt: timestamp().defaultNow().notNull(),
+});
+
+// 결재선 템플릿
+export const approvalLines = knoxSchema.table('approval_lines', {
+ id: uuid().primaryKey().defaultRandom(), // 결재선 아이디 UUID
+ name: text().notNull(), // 결재선 이름
+ description: text(), // 결재선 설명
+ category: text(),
+
+ // 핵심
+ aplns: jsonb().notNull(), // 결재선 구성
+
+ // 메타데이터
+ createdBy: integer() // 결재선 생성자 - eVCP 유저 아이디 기반 참조
+ .notNull()
+ .references(() => users.id, { onDelete: 'set null' }),
+ createdAt: timestamp().defaultNow().notNull(),
+ updatedAt: timestamp().defaultNow().notNull(),
+});
+
+//
+
+// 결재 템플릿 변수
+export const approvalTemplateVariables = knoxSchema.table('approval_template_variables', {
+ id: uuid().primaryKey().defaultRandom(), // 변수 아이디 UUID
+ approvalTemplateId: uuid()
+ .references(() => approvalTemplates.id, { onDelete: 'cascade' }),
+ variableName: text().notNull(), // 변수 이름
+ variableType: text().notNull(), // 변수 타입
+ defaultValue: text(), // 변수 기본값
+ description: text(), // 변수 설명
+
+ // 메타데이터
+ createdBy: integer() // 변수 생성자 - eVCP 유저 아이디 기반 참조
+ .notNull()
+ .references(() => users.id, { onDelete: 'set null' }),
+ createdAt: timestamp().defaultNow().notNull(),
+ updatedAt: timestamp().defaultNow().notNull(),
+});
+
diff --git a/db/schema/procurementRFQ.ts b/db/schema/procurementRFQ.ts
index 18cf5f9d..fe60bb0e 100644
--- a/db/schema/procurementRFQ.ts
+++ b/db/schema/procurementRFQ.ts
@@ -1,5 +1,5 @@
import { foreignKey, pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, check } from "drizzle-orm/pg-core";
-import { eq, sql, and, or, relations } from "drizzle-orm";
+import { eq, sql, relations } from "drizzle-orm";
import { projects } from "./projects";
import { users } from "./users";
import { vendors } from "./vendors";
@@ -12,25 +12,25 @@ export const procurementRfqs = pgTable(
// RFQ 고유 코드
rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001"
- // 프로젝트 참조
- projectId: integer("project_id")
- .references(() => projects.id, { onDelete: "set null" }),
+ // 프로젝트: ECC RFQ는 프로젝트 테이블과 1:N 관계를 가져야 함
+ // WHY?: 여러 프로젝트 혹은 여러 시리즈의 동일 품목을 PR로 묶어 올리기 때문
+ projectId: varchar("project_id", { length: 1000 }),
+ // SS, II, null 값을 가질 수 있음.
+ // SS = 시리즈 통합, II = 품목 통합, 공란 = 통합 없음
series: varchar("series", { length: 50 }),
- // itemId: integer("item_id")
- // .notNull()
- // .references(() => items.id, { onDelete: "cascade" }),
-
- itemCode: varchar("item_code", { length: 100 }),
- itemName: varchar("item_name", { length: 255 }),
+ // 자재코드, 자재명: ECC RFQ는 자재코드, 자재명을 가지지 않음
+ // WHY?: 여러 프로젝트 혹은 여러 시리즈의 동일 품목을 PR로 묶어 올리기 때문
+ // 아래 컬럼은 대표 자재코드, 대표 자재명으로 사용
+ itemCode: varchar("item_code", { length: 100 }),
+ itemName: varchar("item_name", { length: 255 }),
dueDate: date("due_date", { mode: "date" })
- .$type<Date>()
- .notNull(),
+ .$type<Date>(), // 인터페이스한 값은 dueDate가 없으므로 notNull 제약조건 제거
rfqSendDate: date("rfq_send_date", { mode: "date" })
- .$type<Date | null>(), // notNull() 제약조건 제거, null 허용
+ .$type<Date | null>(), // notNull() 제약조건 제거, null 허용 (ECC에서 수신 후 보내지 않은 RFQ)
status: varchar("status", { length: 30 })
.$type<"RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "PO Transfer" | "PO Create">()
@@ -38,7 +38,7 @@ export const procurementRfqs = pgTable(
.notNull(),
rfqSealedYn: boolean("rfq_sealed_yn").default(false),
- picCode: varchar("pic_code", { length: 50 }),
+ picCode: varchar("pic_code", { length: 50 }), // 구매그룹에 대응시킴 (담당자 코드로 3자리)
remark: text("remark"),
// 생성자
diff --git a/db/schema/projects.ts b/db/schema/projects.ts
index a67b2c33..ee3dbf27 100644
--- a/db/schema/projects.ts
+++ b/db/schema/projects.ts
@@ -5,7 +5,7 @@ export const projects = pgTable("projects", {
code: varchar("code", { length: 50 }).notNull(),
name: text("name").notNull(),
type: varchar("type", { length: 20 }).default("ship").notNull(),
-
+ pspid: char('pspid', { length: 24 }).unique(), // 프로젝트ID (ECC), TODO: 매핑 필요
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
})
diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json
index d07293f6..7b59d34a 100644
--- a/i18n/locales/en/menu.json
+++ b/i18n/locales/en/menu.json
@@ -149,13 +149,15 @@
"integration_list": "Interface List Management",
"integration_log": "Interface History Inquiry",
"approval_log": "Approval History Inquiry",
- "approval_path": "Approval Path Management",
+ "approval_template": "Approval Template Management",
+ "approval_line": "Approval Line Management",
"approval_after": "Post-Approval Management",
"email_template": "Email Template Management",
"email_receiver": "Email Recipient Management",
"email_log": "Email Transmission History Inquiry",
"login_history": "Login/Logout History Inquiry",
"page_visits": "Page Access History Inquiry"
+
},
"vendor": {
"sales": {
diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json
index 469b811d..9e3fe7a2 100644
--- a/i18n/locales/ko/menu.json
+++ b/i18n/locales/ko/menu.json
@@ -149,7 +149,8 @@
"integration_list": "인터페이스 목록 관리",
"integration_log": "인터페이스 이력 조회",
"approval_log": "결재 이력 조회",
- "approval_path": "결재 경로 관리",
+ "approval_template": "결재 서식 관리",
+ "approval_line": "결재선 관리",
"approval_after": "결재 후처리 관리",
"email_template": "이메일 서식 관리",
"email_receiver": "이메일 수신인 관리",
diff --git a/lib/approval-line/service.ts b/lib/approval-line/service.ts
new file mode 100644
index 00000000..3000e25f
--- /dev/null
+++ b/lib/approval-line/service.ts
@@ -0,0 +1,341 @@
+'use server';
+
+import db from '@/db/db';
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ ilike,
+ or,
+} from 'drizzle-orm';
+
+import { sql } from 'drizzle-orm';
+import { approvalLines } from '@/db/schema/knox/approvals';
+
+import { filterColumns } from '@/lib/filter-columns';
+
+// ---------------------------------------------
+// Types
+// ---------------------------------------------
+
+export type ApprovalLine = typeof approvalLines.$inferSelect;
+
+export interface ApprovalLineWithUsage extends ApprovalLine {
+ templateCount: number; // 사용 중인 템플릿 수
+}
+
+// ---------------------------------------------
+// List & read helpers
+// ---------------------------------------------
+
+interface ListInput {
+ page: number;
+ perPage: number;
+ search?: string;
+ filters?: Record<string, unknown>[];
+ joinOperator?: 'and' | 'or';
+ sort?: Array<{ id: string; desc: boolean }>;
+}
+
+export async function getApprovalLineList(input: ListInput) {
+ const offset = (input.page - 1) * input.perPage;
+
+ /* ------------------------------------------------------------------
+ * WHERE 절 구성
+ * ----------------------------------------------------------------*/
+ const advancedWhere = filterColumns({
+ table: approvalLines,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ filters: (input.filters ?? []) as any,
+ joinOperator: (input.joinOperator ?? 'and') as 'and' | 'or',
+ });
+
+ // 전역 검색 (name, description)
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(approvalLines.name, s),
+ ilike(approvalLines.description, s),
+ );
+ }
+
+ const conditions = [];
+ if (advancedWhere) conditions.push(advancedWhere);
+ if (globalWhere) conditions.push(globalWhere);
+
+ const where =
+ conditions.length === 0
+ ? undefined
+ : conditions.length === 1
+ ? conditions[0]
+ : and(...conditions);
+
+ /* ------------------------------------------------------------------
+ * ORDER BY 절 구성
+ * ----------------------------------------------------------------*/
+ let orderBy;
+ try {
+ orderBy = input.sort && input.sort.length > 0
+ ? input.sort
+ .map((item) => {
+ if (!item || !item.id || typeof item.id !== 'string') return null;
+ if (!(item.id in approvalLines)) return null;
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const col = approvalLines[item.id];
+ return item.desc ? desc(col) : asc(col);
+ })
+ .filter((v): v is Exclude<typeof v, null> => v !== null)
+ : [desc(approvalLines.updatedAt)];
+ } catch {
+ orderBy = [desc(approvalLines.updatedAt)];
+ }
+
+ /* ------------------------------------------------------------------
+ * 데이터 조회
+ * ----------------------------------------------------------------*/
+ const data = await db
+ .select()
+ .from(approvalLines)
+ .where(where)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const totalResult = await db
+ .select({ count: count() })
+ .from(approvalLines)
+ .where(where);
+
+ const total = totalResult[0]?.count ?? 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return {
+ data,
+ pageCount,
+ };
+}
+
+// ----------------------------------------------------
+// Simple list for options (id, name)
+// ----------------------------------------------------
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function getApprovalLineOptions(category?: string): Promise<Array<{ id: string; name: string; aplns: any[]; category: string | null }>> {
+ const where = category
+ ? eq(approvalLines.category, category)
+ : undefined;
+ const rows = await db
+ .select({ id: approvalLines.id, name: approvalLines.name, aplns: approvalLines.aplns, category: approvalLines.category })
+ .from(approvalLines)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .where(where as any)
+ .orderBy(asc(approvalLines.name));
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return rows as Array<{ id: string; name: string; aplns: any[]; category: string | null }>;
+}
+
+// ----------------------------------------------------
+// Distinct categories for filter
+// ----------------------------------------------------
+export async function getApprovalLineCategories(): Promise<string[]> {
+ const rows = await db
+ .select({ category: approvalLines.category })
+ .from(approvalLines)
+ .where(sql`${approvalLines.category} IS NOT NULL AND ${approvalLines.category} <> ''`)
+ .groupBy(approvalLines.category)
+ .orderBy(asc(approvalLines.category));
+ return rows.map((r) => r.category!).filter(Boolean);
+}
+
+// ----------------------------------------------------
+// Server Action for fetching options by category
+// ----------------------------------------------------
+export async function getApprovalLineOptionsAction(category?: string) {
+ try {
+ const data = await getApprovalLineOptions(category)
+ return { success: true, data }
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : '조회에 실패했습니다.' }
+ }
+}
+
+// ----------------------------------------------------
+// Server Action for fetching distinct categories
+// ----------------------------------------------------
+export async function getApprovalLineCategoriesAction() {
+ try {
+ const data = await getApprovalLineCategories()
+ return { success: true, data }
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : '카테고리 조회에 실패했습니다.' }
+ }
+}
+
+// ----------------------------------------------------
+// Get single approval line
+// ----------------------------------------------------
+export async function getApprovalLine(id: string): Promise<ApprovalLine | null> {
+ const [line] = await db
+ .select()
+ .from(approvalLines)
+ .where(eq(approvalLines.id, id))
+ .limit(1);
+
+ return line || null;
+}
+
+// ----------------------------------------------------
+// Create approval line
+// ----------------------------------------------------
+interface CreateInput {
+ name: string;
+ description?: string;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ aplns: any[]; // 결재선 구성 (JSON)
+ createdBy: number;
+}
+
+export async function createApprovalLine(data: CreateInput): Promise<ApprovalLine> {
+ // 중복 이름 체크
+ const existing = await db
+ .select({ id: approvalLines.id })
+ .from(approvalLines)
+ .where(eq(approvalLines.name, data.name))
+ .limit(1);
+
+ if (existing.length > 0) {
+ throw new Error('이미 존재하는 결재선 이름입니다.');
+ }
+
+ const [newLine] = await db
+ .insert(approvalLines)
+ .values({
+ name: data.name,
+ description: data.description,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ aplns: data.aplns as any,
+ createdBy: data.createdBy,
+ })
+ .returning();
+
+ return newLine;
+}
+
+// ----------------------------------------------------
+// Update approval line
+// ----------------------------------------------------
+interface UpdateInput {
+ name?: string;
+ description?: string;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ aplns?: any[]; // 결재선 구성 (JSON)
+ updatedBy: number;
+}
+
+export async function updateApprovalLine(id: string, data: UpdateInput): Promise<ApprovalLine> {
+ const existing = await getApprovalLine(id);
+ if (!existing) throw new Error('결재선을 찾을 수 없습니다.');
+
+ // 이름 중복 체크 (자신 제외)
+ if (data.name && data.name !== existing.name) {
+ const duplicate = await db
+ .select({ id: approvalLines.id })
+ .from(approvalLines)
+ .where(
+ and(
+ eq(approvalLines.name, data.name),
+ eq(approvalLines.id, id)
+ )
+ )
+ .limit(1);
+
+ if (duplicate.length > 0) {
+ throw new Error('이미 존재하는 결재선 이름입니다.');
+ }
+ }
+
+ // 결재선 업데이트
+ await db
+ .update(approvalLines)
+ .set({
+ name: data.name ?? existing.name,
+ description: data.description ?? existing.description,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ aplns: (data.aplns as any) ?? (existing.aplns as any),
+ updatedAt: new Date(),
+ })
+ .where(eq(approvalLines.id, id));
+
+ const result = await getApprovalLine(id);
+ if (!result) throw new Error('업데이트된 결재선을 조회할 수 없습니다.');
+ return result;
+}
+
+// ----------------------------------------------------
+// Server Actions
+// ----------------------------------------------------
+export async function updateApprovalLineAction(id: string, data: UpdateInput) {
+ try {
+ const updated = await updateApprovalLine(id, data)
+ return { success: true, data: updated }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// ----------------------------------------------------
+// Duplicate approval line
+// ----------------------------------------------------
+export async function duplicateApprovalLine(
+ id: string,
+ newName: string,
+ createdBy: number,
+): Promise<{ success: boolean; error?: string; data?: ApprovalLine }> {
+ try {
+ const existing = await getApprovalLine(id)
+ if (!existing) return { success: false, error: '결재선을 찾을 수 없습니다.' }
+
+ // 새 결재선 생성
+ const duplicated = await createApprovalLine({
+ name: newName,
+ description: existing.description ? `${existing.description} (복사본)` : undefined,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ aplns: existing.aplns as any,
+ createdBy,
+ })
+
+ return { success: true, data: duplicated }
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : '복제에 실패했습니다.' }
+ }
+}
+
+// ----------------------------------------------------
+// Delete (soft delete X -> 실제 삭제)
+// ----------------------------------------------------
+export async function deleteApprovalLine(id: string): Promise<{ success: boolean; error?: string }> {
+ try {
+ await db.delete(approvalLines).where(eq(approvalLines.id, id));
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '삭제에 실패했습니다.',
+ };
+ }
+}
+
+// ----------------------------------------------------
+// Get approval line usage (템플릿에서 사용 중인지 확인)
+// ----------------------------------------------------
+export async function getApprovalLineUsage(): Promise<{ templateCount: number }> {
+ // 현재는 approvalLines가 템플릿과 직접 연결되지 않으므로 0 반환
+ // 추후 템플릿에서 결재선을 참조하는 구조로 변경 시 실제 사용량 계산
+ return { templateCount: 0 };
+} \ No newline at end of file
diff --git a/lib/approval-line/table/approval-line-table-columns.tsx b/lib/approval-line/table/approval-line-table-columns.tsx
new file mode 100644
index 00000000..5b35b92c
--- /dev/null
+++ b/lib/approval-line/table/approval-line-table-columns.tsx
@@ -0,0 +1,197 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { type ApprovalLine } from "../service"
+import { formatApprovalLine } from "../utils/format"
+import { formatDate } from "@/lib/utils"
+import { MoreHorizontal, Copy, Edit, Trash2 } from "lucide-react"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<{
+ type: "update" | "delete" | "duplicate";
+ row: { original: ApprovalLine };
+ } | null>>;
+}
+
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ApprovalLine>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-[2px]"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-[2px]"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "name",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="결재선 이름" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <div className="flex space-x-2">
+ <span className="max-w-[500px] truncate font-medium">
+ {row.getValue("name")}
+ </span>
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="설명" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <div className="flex space-x-2">
+ <span className="max-w-[500px] truncate">
+ {row.getValue("description") || "-"}
+ </span>
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "aplns",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="결재선" />
+ ),
+ cell: ({ row }) => {
+ const aplns = row.getValue("aplns") as unknown as Array<{
+ seq: string;
+ name?: string;
+ emailAddress?: string;
+ role: string;
+ }>;
+ const approvalLineText = formatApprovalLine(aplns);
+
+ return (
+ <div className="flex space-x-2">
+ <div className="flex flex-col gap-1">
+ <div className="max-w-[400px] truncate text-sm">
+ {approvalLineText}
+ <Badge variant="secondary" className="w-fit">
+ {aplns?.length || 0}명
+ </Badge>
+ </div>
+ </div>
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <div className="flex space-x-2">
+ <span className="max-w-[500px] truncate">
+ {formatDate(row.getValue("createdAt"))}
+ </span>
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <div className="flex space-x-2">
+ <span className="max-w-[500px] truncate">
+ {formatDate(row.getValue("updatedAt"))}
+ </span>
+ </div>
+ )
+ },
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => {
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <MoreHorizontal className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "update", row });
+ }}
+ >
+ <Edit className="mr-2 size-4" aria-hidden="true" />
+ 수정하기
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "duplicate", row });
+ }}
+ >
+ <Copy className="mr-2 size-4" aria-hidden="true" />
+ 복제하기
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "delete", row });
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash2 className="mr-2 size-4" aria-hidden="true" />
+ 삭제하기
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 80,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/approval-line/table/approval-line-table-toolbar-actions.tsx b/lib/approval-line/table/approval-line-table-toolbar-actions.tsx
new file mode 100644
index 00000000..6b6600fe
--- /dev/null
+++ b/lib/approval-line/table/approval-line-table-toolbar-actions.tsx
@@ -0,0 +1,73 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { DataTableViewOptions } from "@/components/data-table/data-table-view-options"
+import { Plus, Download, Upload } from "lucide-react"
+import { type ApprovalLine } from "../service"
+
+interface ApprovalLineTableToolbarActionsProps {
+ table: Table<ApprovalLine>
+ onCreateLine: () => void
+}
+
+export function ApprovalLineTableToolbarActions({
+ table,
+ onCreateLine,
+}: ApprovalLineTableToolbarActionsProps) {
+ const isFiltered = table.getState().columnFilters.length > 0
+
+ return (
+ <div className="flex items-center justify-between">
+ <div className="flex flex-1 items-center space-x-2">
+ <Input
+ placeholder="결재선 검색..."
+ value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
+ onChange={(event) =>
+ table.getColumn("name")?.setFilterValue(event.target.value)
+ }
+ className="h-8 w-[150px] lg:w-[250px]"
+ />
+ {isFiltered && (
+ <Button
+ variant="ghost"
+ onClick={() => table.resetColumnFilters()}
+ className="h-8 px-2 lg:px-3"
+ >
+ 초기화
+ </Button>
+ )}
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ className="ml-auto hidden h-8 lg:flex"
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ 가져오기
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="ml-auto hidden h-8 lg:flex"
+ >
+ <Download className="mr-2 h-4 w-4" />
+ 내보내기
+ </Button>
+ <DataTableViewOptions table={table} />
+ <Button
+ variant="outline"
+ size="sm"
+ className="ml-auto h-8"
+ onClick={onCreateLine}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 결재선 생성
+ </Button>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/approval-line/table/approval-line-table.tsx b/lib/approval-line/table/approval-line-table.tsx
new file mode 100644
index 00000000..21b9972c
--- /dev/null
+++ b/lib/approval-line/table/approval-line-table.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import * as React from 'react';
+import { useDataTable } from '@/hooks/use-data-table';
+import { DataTable } from '@/components/data-table/data-table';
+import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar';
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from '@/types/table';
+
+import { getColumns } from './approval-line-table-columns';
+import { getApprovalLineList } from '../service';
+import { type ApprovalLine } from '../service';
+import { ApprovalLineTableToolbarActions } from './approval-line-table-toolbar-actions';
+import { CreateApprovalLineSheet } from './create-approval-line-sheet';
+import { UpdateApprovalLineSheet } from './update-approval-line-sheet';
+import { DuplicateApprovalLineSheet } from './duplicate-approval-line-sheet';
+import { DeleteApprovalLineDialog } from './delete-approval-line-dialog';
+
+interface ApprovalLineTableProps {
+ promises: Promise<[
+ Awaited<ReturnType<typeof getApprovalLineList>>,
+ ]>;
+}
+
+export function ApprovalLineTable({ promises }: ApprovalLineTableProps) {
+ const [{ data, pageCount }] = React.use(promises);
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<ApprovalLine> | null>(null);
+
+ const [showCreateSheet, setShowCreateSheet] = React.useState(false);
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ );
+
+ // 기본 & 고급 필터 필드
+ const filterFields: DataTableFilterField<ApprovalLine>[] = [];
+ const advancedFilterFields: DataTableAdvancedFilterField<ApprovalLine>[] = [
+ {
+ id: 'name',
+ label: '결재선 이름',
+ type: 'text',
+ },
+ {
+ id: 'description',
+ label: '설명',
+ type: 'text',
+ },
+ {
+ id: 'createdAt',
+ label: '생성일',
+ type: 'date',
+ },
+ {
+ id: 'updatedAt',
+ label: '수정일',
+ type: 'date',
+ },
+ ];
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: 'updatedAt', desc: true }],
+ columnPinning: { right: ['actions'] },
+ },
+ getRowId: (row) => String(row.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ApprovalLineTableToolbarActions
+ table={table}
+ onCreateLine={() => setShowCreateSheet(true)}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 새 결재선 생성 Sheet */}
+ <CreateApprovalLineSheet
+ open={showCreateSheet}
+ onOpenChange={setShowCreateSheet}
+ />
+
+ {/* 결재선 수정 Sheet */}
+ <UpdateApprovalLineSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ line={rowAction?.type === "update" ? rowAction.row.original : null}
+ />
+
+ {/* 결재선 복제 Sheet */}
+ <DuplicateApprovalLineSheet
+ open={rowAction?.type === "duplicate"}
+ onOpenChange={() => setRowAction(null)}
+ line={rowAction?.type === "duplicate" ? rowAction.row.original : null}
+ />
+
+ {/* 결재선 삭제 Dialog */}
+ <DeleteApprovalLineDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ lines={rowAction?.type === "delete" ? [rowAction.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ setRowAction(null)
+ // 테이블 새로고침은 server action에서 자동으로 처리됨
+ }}
+ />
+ </>
+ );
+} \ No newline at end of file
diff --git a/lib/approval-line/table/create-approval-line-sheet.tsx b/lib/approval-line/table/create-approval-line-sheet.tsx
new file mode 100644
index 00000000..fdc8cc64
--- /dev/null
+++ b/lib/approval-line/table/create-approval-line-sheet.tsx
@@ -0,0 +1,224 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Separator } from "@/components/ui/separator"
+import { toast } from "sonner"
+import { Loader2 } from "lucide-react"
+import { createApprovalLine } from "../service"
+import { type ApprovalLineFormData, ApprovalLineSchema } from "../validations"
+import { ApprovalLineSelector } from "@/components/knox/approval/ApprovalLineSelector"
+import { OrganizationManagerSelector, type OrganizationManagerItem } from "@/components/common/organization/organization-manager-selector"
+import { useSession } from "next-auth/react"
+
+interface CreateApprovalLineSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function CreateApprovalLineSheet({ open, onOpenChange }: CreateApprovalLineSheetProps) {
+ const { data: session } = useSession();
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+
+ // 고유 ID 생성 함수 (조직 관리자 추가 시 사용)
+ const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
+
+ const form = useForm<ApprovalLineFormData>({
+ resolver: zodResolver(ApprovalLineSchema),
+ defaultValues: {
+ name: "",
+ description: "",
+ aplns: [
+ // 기안자는 항상 첫 번째로 고정 (플레이스홀더)
+ {
+ id: generateUniqueId(),
+ epId: undefined,
+ userId: undefined,
+ emailAddress: undefined,
+ name: "기안자",
+ deptName: undefined,
+ role: "0",
+ seq: "0",
+ opinion: "",
+ },
+ ],
+ },
+ });
+
+ const aplns = form.watch("aplns");
+
+ // 조직 관리자 추가 (공용 선택기 외 보조 입력 경로)
+ const addOrganizationManagers = (managers: OrganizationManagerItem[]) => {
+ const next = [...aplns];
+ const uniqueSeqs = Array.from(new Set(next.map((a) => parseInt(a.seq))));
+ const maxSeq = uniqueSeqs.length ? Math.max(...uniqueSeqs) : 0;
+
+ managers.forEach((manager, idx) => {
+ const exists = next.findIndex((a) => a.epId === manager.managerId);
+ if (exists === -1) {
+ const newSeqNum = Math.max(1, maxSeq + 1 + idx);
+ const newSeq = newSeqNum.toString();
+ next.push({
+ id: generateUniqueId(),
+ epId: manager.managerId,
+ userId: undefined,
+ emailAddress: undefined,
+ name: manager.managerName,
+ deptName: manager.departmentName,
+ role: "1",
+ seq: newSeq,
+ opinion: "",
+ });
+ }
+ });
+
+ form.setValue("aplns", next, { shouldDirty: true });
+ };
+
+ const onSubmit = async (data: ApprovalLineFormData) => {
+ setIsSubmitting(true);
+ try {
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다.");
+ return;
+ }
+
+ await createApprovalLine({
+ name: data.name,
+ description: data.description,
+ aplns: data.aplns,
+ createdBy: Number(session.user.id),
+ });
+
+ toast.success("결재선이 성공적으로 생성되었습니다.");
+ form.reset();
+ onOpenChange(false);
+ } catch {
+ toast.error("결재선 생성 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-full sm:max-w-4xl overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle>결재선 생성</SheetTitle>
+ <SheetDescription>
+ 새로운 결재선을 생성합니다. 결재자를 추가하고 순서를 조정할 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="mt-6">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>결재선 이름 *</FormLabel>
+ <FormControl>
+ <Input placeholder="결재선 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Textarea placeholder="결재선에 대한 설명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 결재 경로 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">결재 경로</h3>
+
+ <ApprovalLineSelector
+ value={aplns}
+ onChange={(next) => form.setValue("aplns", next, { shouldDirty: true })}
+ placeholder="결재자를 검색하세요..."
+ domainFilter={{ type: "exclude", domains: ["partners"] }}
+ maxSelections={10}
+ />
+
+ {/* 조직 관리자 추가 (선택 사항) */}
+ {/* <div className="p-4 border border-dashed border-gray-300 rounded-lg">
+ <div className="mb-2">
+ <label className="text-sm font-medium text-gray-700">조직 관리자로 추가</label>
+ <p className="text-xs text-gray-500">조직별 책임자를 검색하여 추가하세요</p>
+ </div>
+ <OrganizationManagerSelector
+ selectedManagers={[]}
+ onManagersChange={addOrganizationManagers}
+ placeholder="조직 관리자를 검색하세요..."
+ maxSelections={10}
+ />
+ </div> */}
+ </div>
+
+ <Separator />
+
+ {/* 제출 버튼 */}
+ <div className="flex justify-end space-x-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ 생성 중...
+ </>
+ ) : (
+ "결재선 생성"
+ )}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/approval-line/table/delete-approval-line-dialog.tsx b/lib/approval-line/table/delete-approval-line-dialog.tsx
new file mode 100644
index 00000000..aa1d8949
--- /dev/null
+++ b/lib/approval-line/table/delete-approval-line-dialog.tsx
@@ -0,0 +1,168 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import { Loader2, Trash2, AlertTriangle } from "lucide-react"
+import { deleteApprovalLine, getApprovalLineUsage } from "../service"
+import { type ApprovalLine } from "../service"
+
+interface DeleteApprovalLineDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ lines: ApprovalLine[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteApprovalLineDialog({
+ open,
+ onOpenChange,
+ lines,
+ showTrigger = true,
+ onSuccess,
+}: DeleteApprovalLineDialogProps) {
+ const [isDeleting, setIsDeleting] = React.useState(false)
+ const [usageInfo, setUsageInfo] = React.useState<{ templateCount: number } | null>(null)
+
+ // 사용량 정보 조회
+ React.useEffect(() => {
+ if (open && lines.length === 1) {
+ getApprovalLineUsage(lines[0].id).then(setUsageInfo)
+ }
+ }, [open, lines])
+
+ const handleDelete = async () => {
+ setIsDeleting(true)
+
+ try {
+ const deletePromises = lines.map((line) => deleteApprovalLine(line.id))
+ const results = await Promise.all(deletePromises)
+
+ const successCount = results.filter((r) => r.success).length
+ const errorCount = results.length - successCount
+
+ if (successCount > 0) {
+ toast.success(`${successCount}개의 결재선이 삭제되었습니다.`)
+ onSuccess?.()
+ onOpenChange(false)
+ }
+
+ if (errorCount > 0) {
+ toast.error(`${errorCount}개의 결재선 삭제에 실패했습니다.`)
+ }
+ } catch (error) {
+ toast.error("삭제 중 오류가 발생했습니다.")
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ const isSingleLine = lines.length === 1
+ const line = isSingleLine ? lines[0] : null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Trash2 className="h-5 w-5 text-destructive" />
+ 결재선 삭제
+ </DialogTitle>
+ <DialogDescription>
+ {isSingleLine
+ ? `"${line?.name}" 결재선을 삭제하시겠습니까?`
+ : `${lines.length}개의 결재선을 삭제하시겠습니까?`}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 사용량 정보 표시 */}
+ {isSingleLine && usageInfo && (
+ <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
+ <div className="flex items-center gap-2 text-yellow-700">
+ <AlertTriangle className="w-4 h-4" />
+ <span className="font-medium">사용 중인 결재선</span>
+ </div>
+ <p className="text-sm text-yellow-600 mt-1">
+ 이 결재선은 {usageInfo.templateCount}개의 템플릿에서 사용되고 있습니다.
+ </p>
+ </div>
+ )}
+
+ {/* 삭제할 결재선 목록 */}
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">삭제할 결재선:</h4>
+ <div className="space-y-2 max-h-40 overflow-y-auto">
+ {lines.map((line) => (
+ <div
+ key={line.id}
+ className="flex items-center justify-between p-3 border rounded-lg"
+ >
+ <div className="flex-1">
+ <div className="font-medium">{line.name}</div>
+ {line.description && (
+ <div className="text-sm text-gray-500">
+ {line.description}
+ </div>
+ )}
+ <div className="text-xs text-gray-400 mt-1">
+ 결재자 {(line.aplns as any[])?.length || 0}명
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* 경고 메시지 */}
+ <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
+ <div className="flex items-center gap-2 text-red-700">
+ <AlertTriangle className="w-4 h-4" />
+ <span className="font-medium">주의</span>
+ </div>
+ <p className="text-sm text-red-600 mt-1">
+ 삭제된 결재선은 복구할 수 없습니다. 이 작업은 되돌릴 수 없습니다.
+ </p>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isDeleting}
+ >
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isDeleting}
+ >
+ {isDeleting ? (
+ <>
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ 삭제 중...
+ </>
+ ) : (
+ <>
+ <Trash2 className="w-4 h-4 mr-2" />
+ 삭제
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/approval-line/table/duplicate-approval-line-sheet.tsx b/lib/approval-line/table/duplicate-approval-line-sheet.tsx
new file mode 100644
index 00000000..0bb3ab2c
--- /dev/null
+++ b/lib/approval-line/table/duplicate-approval-line-sheet.tsx
@@ -0,0 +1,206 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { toast } from "sonner"
+import { Loader2, Copy } from "lucide-react"
+import { duplicateApprovalLine } from "../service"
+import { type ApprovalLine } from "../service"
+import { useSession } from "next-auth/react"
+
+const duplicateSchema = z.object({
+ name: z.string().min(1, "결재선 이름은 필수입니다"),
+ description: z.string().optional(),
+})
+
+type DuplicateFormData = z.infer<typeof duplicateSchema>
+
+interface DuplicateApprovalLineSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ line: ApprovalLine | null
+}
+
+export function DuplicateApprovalLineSheet({
+ open,
+ onOpenChange,
+ line,
+}: DuplicateApprovalLineSheetProps) {
+ const { data: session } = useSession()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const form = useForm<DuplicateFormData>({
+ resolver: zodResolver(duplicateSchema),
+ defaultValues: {
+ name: line ? `${line.name} (복사본)` : "",
+ description: line?.description ? `${line.description} (복사본)` : "",
+ },
+ })
+
+ // line이 변경될 때 폼 초기화
+ React.useEffect(() => {
+ if (line) {
+ form.reset({
+ name: `${line.name} (복사본)`,
+ description: line.description ? `${line.description} (복사본)` : "",
+ })
+ }
+ }, [line, form])
+
+ const onSubmit = async (data: DuplicateFormData) => {
+ if (!line || !session?.user?.id) {
+ toast.error("복제할 결재선이 없거나 로그인이 필요합니다.")
+ return
+ }
+
+ setIsSubmitting(true)
+
+ try {
+ const result = await duplicateApprovalLine(
+ line.id,
+ data.name,
+ session.user.id
+ )
+
+ if (result.success) {
+ toast.success("결재선이 성공적으로 복제되었습니다.")
+ form.reset()
+ onOpenChange(false)
+ } else {
+ toast.error(result.error || "복제에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("복제 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (!line) return null
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle className="flex items-center gap-2">
+ <Copy className="h-5 w-5" />
+ 결재선 복제
+ </SheetTitle>
+ <SheetDescription>
+ "{line.name}" 결재선을 복제하여 새로운 결재선을 만듭니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="mt-6">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 원본 정보 표시 */}
+ <div className="p-4 bg-gray-50 border rounded-lg">
+ <h4 className="font-medium text-sm mb-2">원본 결재선 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div>
+ <span className="font-medium">이름:</span> {line.name}
+ </div>
+ {line.description && (
+ <div>
+ <span className="font-medium">설명:</span> {line.description}
+ </div>
+ )}
+ <div>
+ <span className="font-medium">결재자:</span> {(line.aplns as any[])?.length || 0}명
+ </div>
+ </div>
+ </div>
+
+ {/* 새 결재선 정보 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>새 결재선 이름 *</FormLabel>
+ <FormControl>
+ <Input placeholder="새 결재선 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormDescription>
+ 원본과 구분할 수 있는 이름을 입력하세요.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="새 결재선에 대한 설명을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 선택사항입니다. 결재선의 용도나 특징을 설명할 수 있습니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 제출 버튼 */}
+ <div className="flex justify-end space-x-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ 복제 중...
+ </>
+ ) : (
+ <>
+ <Copy className="w-4 h-4 mr-2" />
+ 결재선 복제
+ </>
+ )}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/approval-line/table/update-approval-line-sheet.tsx b/lib/approval-line/table/update-approval-line-sheet.tsx
new file mode 100644
index 00000000..efc720de
--- /dev/null
+++ b/lib/approval-line/table/update-approval-line-sheet.tsx
@@ -0,0 +1,264 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Separator } from "@/components/ui/separator"
+import { toast } from "sonner"
+import { Loader2, Edit } from "lucide-react"
+import { updateApprovalLine, type ApprovalLine } from "../service"
+import { type ApprovalLineFormData, ApprovalLineSchema } from "../validations"
+import { OrganizationManagerSelector, type OrganizationManagerItem } from "@/components/common/organization/organization-manager-selector"
+import { useSession } from "next-auth/react"
+import { ApprovalLineSelector } from "@/components/knox/approval/ApprovalLineSelector"
+
+interface UpdateApprovalLineSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ line: ApprovalLine | null
+}
+
+// 최소 형태의 Apln 아이템 타입 (line.aplns JSON 구조 대응)
+interface MinimalAplnItem {
+ id: string
+ epId?: string
+ userId?: string
+ emailAddress?: string
+ name?: string
+ deptName?: string
+ role: "0" | "1" | "2" | "3" | "4" | "7" | "9"
+ seq: string
+ opinion?: string
+ [key: string]: unknown
+}
+
+export function UpdateApprovalLineSheet({ open, onOpenChange, line }: UpdateApprovalLineSheetProps) {
+ const { data: session } = useSession();
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+
+ // 고유 ID 생성 함수 (조직 관리자 추가 시 사용)
+ const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
+
+ const form = useForm<ApprovalLineFormData>({
+ resolver: zodResolver(ApprovalLineSchema),
+ defaultValues: {
+ name: "",
+ description: "",
+ aplns: [],
+ },
+ });
+
+ // line이 변경될 때 폼 초기화
+ React.useEffect(() => {
+ if (line) {
+ const existingAplns = (line.aplns as unknown as MinimalAplnItem[]) || [];
+
+ // 기안자가 없으면 추가
+ const hasDraft = existingAplns.some((a) => String(a.seq) === "0");
+ let nextAplns: MinimalAplnItem[] = existingAplns;
+
+ if (!hasDraft) {
+ nextAplns = [
+ {
+ id: generateUniqueId(),
+ epId: undefined,
+ userId: undefined,
+ emailAddress: undefined,
+ name: "기안자",
+ deptName: undefined,
+ role: "0",
+ seq: "0",
+ opinion: "",
+ },
+ ...existingAplns,
+ ];
+ }
+
+ form.reset({
+ name: line.name,
+ description: line.description || "",
+ aplns: nextAplns as ApprovalLineFormData["aplns"],
+ });
+ }
+ }, [line, form]);
+
+ const aplns = form.watch("aplns");
+
+ // 조직 관리자 추가 (공용 선택기 외 보조 입력 경로)
+ const addOrganizationManagers = (managers: OrganizationManagerItem[]) => {
+ const next = [...aplns];
+ const uniqueSeqs = Array.from(new Set(next.map((a) => parseInt(a.seq))));
+ const maxSeq = uniqueSeqs.length ? Math.max(...uniqueSeqs) : 0;
+
+ managers.forEach((manager, idx) => {
+ const exists = next.findIndex((a) => a.epId === manager.managerId);
+ if (exists === -1) {
+ const newSeqNum = Math.max(1, maxSeq + 1 + idx);
+ const newSeq = newSeqNum.toString();
+ next.push({
+ id: generateUniqueId(),
+ epId: manager.managerId,
+ userId: undefined,
+ emailAddress: undefined,
+ name: manager.managerName,
+ deptName: manager.departmentName,
+ role: "1",
+ seq: newSeq,
+ opinion: "",
+ });
+ }
+ });
+
+ form.setValue("aplns", next, { shouldDirty: true });
+ };
+
+ const onSubmit = async (data: ApprovalLineFormData) => {
+ if (!line || !session?.user?.id) {
+ toast.error("수정할 결재선이 없거나 로그인이 필요합니다.");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ await updateApprovalLine(line.id, {
+ name: data.name,
+ description: data.description,
+ aplns: data.aplns,
+ updatedBy: Number(session.user.id),
+ });
+
+ toast.success("결재선이 성공적으로 수정되었습니다.");
+ onOpenChange(false);
+ } catch {
+ toast.error("결재선 수정 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ if (!line) return null;
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-full sm:max-w-4xl overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle className="flex items-center gap-2">
+ <Edit className="h-5 w-5" />
+ 결재선 수정
+ </SheetTitle>
+ <SheetDescription>
+ &quot;{line.name}&quot; 결재선을 수정합니다. 결재자를 추가하고 순서를 조정할 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="mt-6">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>결재선 이름 *</FormLabel>
+ <FormControl>
+ <Input placeholder="결재선 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Textarea placeholder="결재선에 대한 설명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 결재 경로 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">결재 경로</h3>
+
+ <ApprovalLineSelector
+ value={aplns}
+ onChange={(next) => form.setValue("aplns", next, { shouldDirty: true })}
+ placeholder="결재자를 검색하세요..."
+ domainFilter={{ type: "exclude", domains: ["partners"] }}
+ maxSelections={10}
+ />
+
+ {/* 조직 관리자 추가 (선택 사항) */}
+ {/* <div className="p-4 border border-dashed border-gray-300 rounded-lg">
+ <div className="mb-2">
+ <label className="text-sm font-medium text-gray-700">조직 관리자로 추가</label>
+ <p className="text-xs text-gray-500">조직별 책임자를 검색하여 추가하세요</p>
+ </div>
+ <OrganizationManagerSelector
+ selectedManagers={[]}
+ onManagersChange={addOrganizationManagers}
+ placeholder="조직 관리자를 검색하세요..."
+ maxSelections={10}
+ />
+ </div> */}
+ </div>
+
+ <Separator />
+
+ {/* 제출 버튼 */}
+ <div className="flex justify-end space-x-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ 수정 중...
+ </>
+ ) : (
+ "결재선 수정"
+ )}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/approval-line/utils/format.ts b/lib/approval-line/utils/format.ts
new file mode 100644
index 00000000..d74c7d1e
--- /dev/null
+++ b/lib/approval-line/utils/format.ts
@@ -0,0 +1,53 @@
+export interface SimpleApln {
+ seq: string
+ name?: string
+ emailAddress?: string
+ role: string
+}
+
+export function formatApprovalLine(aplns: SimpleApln[]): string {
+ if (!aplns || aplns.length === 0) return '결재자 없음'
+
+ const roleMap: Record<string, string> = {
+ '0': '기안',
+ '1': '결재',
+ '2': '합의',
+ '3': '후결',
+ '4': '병렬합의',
+ '7': '병렬결재',
+ '9': '통보',
+ }
+
+ const groupedBySeq = aplns.reduce<Record<string, SimpleApln[]>>((groups, apln) => {
+ const seq = apln.seq
+ if (!groups[seq]) groups[seq] = []
+ groups[seq].push(apln)
+ return groups
+ }, {})
+
+ const sortedSeqs = Object.keys(groupedBySeq).sort((a, b) => parseInt(a) - parseInt(b))
+
+ return sortedSeqs
+ .map((seq) => {
+ const group = groupedBySeq[seq]
+ const isParallel = group.length > 1 || group.some((apln) => apln.role === '4' || apln.role === '7')
+ if (isParallel) {
+ const parallelMembers = group
+ .map((apln) => {
+ const name = apln.name || apln.emailAddress || '이름없음'
+ const role = roleMap[apln.role] || '알수없음'
+ return `${name}(${role})`
+ })
+ .join(', ')
+ return `[${parallelMembers}]`
+ } else {
+ const apln = group[0]
+ const name = apln.name || apln.emailAddress || '이름없음'
+ const role = roleMap[apln.role] || '알수없음'
+ return `${name}(${role})`
+ }
+ })
+ .join(' → ')
+}
+
+
diff --git a/lib/approval-line/validations.ts b/lib/approval-line/validations.ts
new file mode 100644
index 00000000..4f55454a
--- /dev/null
+++ b/lib/approval-line/validations.ts
@@ -0,0 +1,24 @@
+import { z } from 'zod';
+import { SearchParamsApprovalTemplateCache } from '@/lib/approval-template/validations';
+
+// 결재선 검색 파라미터 스키마 (기존 템플릿 검증 스키마 재사용)
+export const SearchParamsApprovalLineCache = SearchParamsApprovalTemplateCache;
+
+// 결재선 생성/수정 스키마
+export const ApprovalLineSchema = z.object({
+ name: z.string().min(1, '결재선 이름은 필수입니다'),
+ description: z.string().optional(),
+ aplns: z.array(z.object({
+ id: z.string(),
+ epId: z.string().optional(),
+ userId: z.string().optional(),
+ emailAddress: z.string().email('유효한 이메일 주소를 입력해주세요').optional(),
+ name: z.string().optional(),
+ deptName: z.string().optional(),
+ role: z.enum(['0', '1', '2', '3', '4', '7', '9']),
+ seq: z.string(),
+ opinion: z.string().optional()
+ })).min(1, '최소 1개의 결재 경로가 필요합니다'),
+});
+
+export type ApprovalLineFormData = z.infer<typeof ApprovalLineSchema>; \ No newline at end of file
diff --git a/lib/approval-template/editor/approval-template-editor.tsx b/lib/approval-template/editor/approval-template-editor.tsx
new file mode 100644
index 00000000..f23ac4bd
--- /dev/null
+++ b/lib/approval-template/editor/approval-template-editor.tsx
@@ -0,0 +1,257 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { Loader, Save, ArrowLeft } from "lucide-react"
+import Link from "next/link"
+
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import RichTextEditor from "@/components/rich-text-editor/RichTextEditor"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Separator } from "@/components/ui/separator"
+import { Badge } from "@/components/ui/badge"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { formatApprovalLine } from "@/lib/approval-line/utils/format"
+import { getApprovalLineOptionsAction } from "@/lib/approval-line/service"
+
+import { type ApprovalTemplate } from "@/lib/approval-template/service"
+import { type Editor } from "@tiptap/react"
+import { updateApprovalTemplateAction } from "@/lib/approval-template/service"
+import { useSession } from "next-auth/react"
+
+interface ApprovalTemplateEditorProps {
+ templateId: string
+ initialTemplate: ApprovalTemplate
+ staticVariables?: Array<{ variableName: string }>
+ approvalLineOptions: Array<{ id: string; name: string }>
+ approvalLineCategories: string[]
+}
+
+export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVariables = [], approvalLineOptions, approvalLineCategories }: ApprovalTemplateEditorProps) {
+ const { data: session } = useSession()
+ const [rte, setRte] = React.useState<Editor | null>(null)
+ const [template, setTemplate] = React.useState(initialTemplate)
+ const [isSaving, startSaving] = React.useTransition()
+
+ // 편집기에 전달할 변수 목록
+ // 템플릿(DB) 변수 + 정적(config) 변수 병합
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const dbVariables: string[] = Array.isArray((template as any).variables)
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (template as any).variables.map((v: any) => v.variableName)
+ : []
+
+ const mergedVariables = Array.from(new Set([...dbVariables, ...staticVariables.map((v) => v.variableName)]))
+
+ const variableOptions = mergedVariables.map((name) => ({ label: name, html: `{{${name}}}` }))
+
+ const [form, setForm] = React.useState({
+ name: template.name,
+ subject: template.subject,
+ content: template.content,
+ description: template.description ?? "",
+ category: template.category ?? "",
+ approvalLineId: (template as { approvalLineId?: string | null }).approvalLineId ?? "",
+ })
+
+ const [category, setCategory] = React.useState(form.category ?? (approvalLineCategories[0] ?? ""))
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const [lineOptions, setLineOptions] = React.useState(approvalLineOptions as Array<{ id: string; name: string; aplns?: any[]; category?: string | null }>)
+ const [isLoadingLines, setIsLoadingLines] = React.useState(false)
+
+ React.useEffect(() => {
+ let active = true
+ const run = async () => {
+ setIsLoadingLines(true)
+ const { success, data } = await getApprovalLineOptionsAction(category || undefined)
+ if (active) {
+ setIsLoadingLines(false)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (success && data) setLineOptions(data as any)
+ // 카테고리 바뀌면 결재선 선택 초기화
+ setForm((prev) => ({ ...prev, category, approvalLineId: "" }))
+ }
+ }
+ run()
+ return () => {
+ active = false
+ }
+ }, [category])
+
+ function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
+ const { name, value } = e.target
+ setForm((prev) => ({ ...prev, [name]: value }))
+ }
+
+ function handleSave() {
+ startSaving(async () => {
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ const { success, error, data } = await updateApprovalTemplateAction(templateId, {
+ name: form.name,
+ subject: form.subject,
+ content: form.content,
+ description: form.description,
+ category: form.category || undefined,
+ approvalLineId: form.approvalLineId ? form.approvalLineId : null,
+ updatedBy: Number(session.user.id),
+ })
+
+ if (!success || error || !data) {
+ toast.error(error ?? "저장에 실패했습니다")
+ return
+ }
+
+ setTemplate(data)
+ toast.success("저장되었습니다")
+ })
+ }
+
+ return (
+ <div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
+ {/* Header */}
+ <div className="flex items-center gap-4">
+ <Button variant="ghost" size="icon" asChild>
+ <Link href="/evcp/approval/template">
+ <ArrowLeft className="h-4 w-4" />
+ </Link>
+ </Button>
+ <div className="flex-1">
+ <div className="flex items-center gap-3">
+ <h1 className="text-2xl font-semibold">{template.name}</h1>
+ <Badge variant="outline" className="text-xs">
+ 최근 수정: {new Date(template.updatedAt).toLocaleDateString("ko-KR")}
+ </Badge>
+ {template.category && (
+ <Badge variant="secondary" className="text-xs">{template.category}</Badge>
+ )}
+ </div>
+ <p className="text-sm text-muted-foreground">{template.description || "결재 템플릿 편집"}</p>
+ </div>
+ <Button onClick={handleSave} disabled={isSaving}>
+ {isSaving && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ <Save className="mr-2 h-4 w-4" /> 저장
+ </Button>
+ </div>
+
+ <Separator />
+
+ <Tabs defaultValue="editor" className="flex-1">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="editor">편집</TabsTrigger>
+ <TabsTrigger value="preview">미리보기</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="editor" className="mt-4 flex flex-col gap-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <label className="text-sm font-medium">이름</label>
+ <Input name="name" value={form.name} onChange={handleChange} />
+ </div>
+ <div className="space-y-2">
+ <label className="text-sm font-medium">카테고리</label>
+ <Select
+ value={category}
+ onValueChange={(value) => setCategory(value)}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="카테고리를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {approvalLineCategories.map((cat) => (
+ <SelectItem key={cat} value={cat}>
+ {cat}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="space-y-2">
+ <label className="text-sm font-medium">설명 (선택)</label>
+ <Input name="description" value={form.description} onChange={handleChange} />
+ </div>
+ <div className="space-y-2">
+ <label className="text-sm font-medium">제목</label>
+ <Input name="subject" value={form.subject} onChange={handleChange} />
+ </div>
+ <div className="space-y-2">
+ <label className="text-sm font-medium">결재선</label>
+ <Select
+ value={form.approvalLineId}
+ onValueChange={(value) => setForm((prev) => ({ ...prev, approvalLineId: value }))}
+ disabled={!category || isLoadingLines}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={category ? (isLoadingLines ? "불러오는 중..." : "결재선을 선택하세요") : "카테고리를 먼저 선택하세요"} />
+ </SelectTrigger>
+ <SelectContent>
+ {lineOptions.map((opt) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const label = opt.aplns ? `${opt.name} — ${formatApprovalLine(opt.aplns as any)}` : opt.name
+ return (
+ <SelectItem key={opt.id} value={opt.id}>
+ {label}
+ </SelectItem>
+ )
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader>
+ <CardTitle>HTML 내용</CardTitle>
+ <CardDescription>표, 이미지, 변수 등을 자유롭게 편집하세요.</CardDescription>
+ </CardHeader>
+ <CardContent>
+ {variableOptions.length > 0 && (
+ <div className="mb-4 flex flex-wrap gap-2">
+ {variableOptions.map((v: { label: string; html: string }) => (
+ <Button
+ key={v.label}
+ variant="outline"
+ size="sm"
+ onClick={() => rte?.chain().focus().insertContent(v.html).run()}
+ >
+ {`{{${v.label}}}`}
+ </Button>
+ ))}
+ </div>
+ )}
+ <RichTextEditor
+ value={form.content}
+ onChange={(val) => setForm((prev) => ({ ...prev, content: val }))}
+ onReady={(editor) => setRte(editor)}
+ height="400px"
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="preview" className="mt-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>미리보기</CardTitle>
+ </CardHeader>
+ <CardContent className="border rounded-md p-4 overflow-auto bg-background">
+ <div dangerouslySetInnerHTML={{ __html: form.content }} />
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+ </div>
+ )
+}
diff --git a/lib/approval-template/service.ts b/lib/approval-template/service.ts
new file mode 100644
index 00000000..5dc989d9
--- /dev/null
+++ b/lib/approval-template/service.ts
@@ -0,0 +1,330 @@
+'use server';
+
+import db from '@/db/db';
+import {
+ and,
+ asc,
+ count,
+ desc,
+ eq,
+ ilike,
+ or,
+} from 'drizzle-orm';
+
+import {
+ approvalTemplates,
+ approvalTemplateVariables,
+ approvalTemplateHistory,
+} from '@/db/schema/knox/approvals';
+
+import { filterColumns } from '@/lib/filter-columns';
+
+// ---------------------------------------------
+// Types
+// ---------------------------------------------
+
+export type ApprovalTemplate = typeof approvalTemplates.$inferSelect;
+export type ApprovalTemplateVariable =
+ typeof approvalTemplateVariables.$inferSelect;
+export type ApprovalTemplateHistory =
+ typeof approvalTemplateHistory.$inferSelect;
+
+export interface ApprovalTemplateWithVariables extends ApprovalTemplate {
+ variables: ApprovalTemplateVariable[];
+}
+
+// ---------------------------------------------
+// List & read helpers
+// ---------------------------------------------
+
+interface ListInput {
+ page: number;
+ perPage: number;
+ search?: string;
+ filters?: Record<string, unknown>[];
+ joinOperator?: 'and' | 'or';
+ sort?: Array<{ id: string; desc: boolean }>;
+}
+
+export async function getApprovalTemplateList(input: ListInput) {
+ const offset = (input.page - 1) * input.perPage;
+
+ /* ------------------------------------------------------------------
+ * WHERE 절 구성
+ * ----------------------------------------------------------------*/
+ const advancedWhere = filterColumns({
+ table: approvalTemplates,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 전역 검색 (name, subject, description, category)
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(approvalTemplates.name, s),
+ ilike(approvalTemplates.subject, s),
+ ilike(approvalTemplates.description, s),
+ ilike(approvalTemplates.category, s),
+ );
+ }
+
+ const conditions = [];
+ if (advancedWhere) conditions.push(advancedWhere);
+ if (globalWhere) conditions.push(globalWhere);
+
+ const where =
+ conditions.length === 0
+ ? undefined
+ : conditions.length === 1
+ ? conditions[0]
+ : and(...conditions);
+
+ /* ------------------------------------------------------------------
+ * ORDER BY 절 구성
+ * ----------------------------------------------------------------*/
+ let orderBy;
+ try {
+ orderBy = input.sort && input.sort.length > 0
+ ? input.sort
+ .map((item) => {
+ if (!item || !item.id || typeof item.id !== 'string') return null;
+ if (!(item.id in approvalTemplates)) return null;
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const col = approvalTemplates[item.id];
+ return item.desc ? desc(col) : asc(col);
+ })
+ .filter((v): v is Exclude<typeof v, null> => v !== null)
+ : [desc(approvalTemplates.updatedAt)];
+ } catch {
+ orderBy = [desc(approvalTemplates.updatedAt)];
+ }
+
+ /* ------------------------------------------------------------------
+ * 데이터 조회
+ * ----------------------------------------------------------------*/
+ const data = await db
+ .select()
+ .from(approvalTemplates)
+ .where(where)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const totalResult = await db
+ .select({ count: count() })
+ .from(approvalTemplates)
+ .where(where);
+
+ const total = totalResult[0]?.count ?? 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return {
+ data,
+ pageCount,
+ };
+}
+
+// ----------------------------------------------------
+// Get single template + variables
+// ----------------------------------------------------
+export async function getApprovalTemplate(id: string): Promise<ApprovalTemplateWithVariables | null> {
+ const [template] = await db
+ .select()
+ .from(approvalTemplates)
+ .where(eq(approvalTemplates.id, id))
+ .limit(1);
+
+ if (!template) return null;
+
+ const variables = await db
+ .select()
+ .from(approvalTemplateVariables)
+ .where(eq(approvalTemplateVariables.approvalTemplateId, id))
+ .orderBy(approvalTemplateVariables.variableName);
+
+ return {
+ ...template,
+ variables,
+ };
+}
+
+// ----------------------------------------------------
+// Create template
+// ----------------------------------------------------
+interface CreateInput {
+ name: string;
+ subject: string;
+ content: string;
+ category?: string;
+ description?: string;
+ createdBy: number;
+ approvalLineId?: string | null;
+ variables?: Array<{
+ variableName: string;
+ variableType: string;
+ defaultValue?: string;
+ description?: string;
+ }>;
+}
+
+export async function createApprovalTemplate(data: CreateInput): Promise<ApprovalTemplateWithVariables> {
+ // 중복 이름 체크 (옵션)
+ const existing = await db
+ .select({ id: approvalTemplates.id })
+ .from(approvalTemplates)
+ .where(eq(approvalTemplates.name, data.name))
+ .limit(1);
+
+ if (existing.length > 0) {
+ throw new Error('이미 존재하는 템플릿 이름입니다.');
+ }
+
+ const [newTemplate] = await db
+ .insert(approvalTemplates)
+ .values({
+ name: data.name,
+ subject: data.subject,
+ content: data.content,
+ category: data.category,
+ description: data.description,
+ approvalLineId: data.approvalLineId ?? null,
+ createdBy: data.createdBy,
+ })
+ .returning();
+
+ if (data.variables?.length) {
+ const variableRows = data.variables.map((v) => ({
+ approvalTemplateId: newTemplate.id,
+ variableName: v.variableName,
+ variableType: v.variableType,
+ defaultValue: v.defaultValue,
+ description: v.description,
+ createdBy: data.createdBy,
+ }));
+ await db.insert(approvalTemplateVariables).values(variableRows);
+ }
+
+ const result = await getApprovalTemplate(newTemplate.id);
+ if (!result) throw new Error('생성된 템플릿을 조회할 수 없습니다.');
+ return result;
+}
+
+// ----------------------------------------------------
+// Update template
+// ----------------------------------------------------
+interface UpdateInput {
+ name?: string;
+ subject?: string;
+ content?: string;
+ description?: string;
+ category?: string;
+ approvalLineId?: string | null;
+ updatedBy: number;
+}
+
+export async function updateApprovalTemplate(id: string, data: UpdateInput): Promise<ApprovalTemplateWithVariables> {
+ const existing = await getApprovalTemplate(id);
+ if (!existing) throw new Error('템플릿을 찾을 수 없습니다.');
+
+ // 버전 계산 - 현재 히스토리 카운트 + 1
+ const versionResult = await db
+ .select({ count: count() })
+ .from(approvalTemplateHistory)
+ .where(eq(approvalTemplateHistory.templateId, id));
+ const nextVersion = Number(versionResult[0]?.count ?? 0) + 1;
+
+ // 히스토리 저장
+ await db.insert(approvalTemplateHistory).values({
+ templateId: id,
+ version: nextVersion,
+ subject: existing.subject,
+ content: existing.content,
+ changeDescription: '템플릿 업데이트',
+ changedBy: data.updatedBy,
+ });
+
+ // 템플릿 업데이트
+ await db
+ .update(approvalTemplates)
+ .set({
+ name: data.name ?? existing.name,
+ subject: data.subject ?? existing.subject,
+ content: data.content ?? existing.content,
+ description: data.description ?? existing.description,
+ category: data.category ?? existing.category,
+ approvalLineId: data.approvalLineId === undefined ? existing.approvalLineId : data.approvalLineId,
+ updatedAt: new Date(),
+ })
+ .where(eq(approvalTemplates.id, id));
+
+ const result = await getApprovalTemplate(id);
+ if (!result) throw new Error('업데이트된 템플릿을 조회할 수 없습니다.');
+ return result;
+}
+
+// ----------------------------------------------------
+// Server Actions
+// ----------------------------------------------------
+export async function updateApprovalTemplateAction(id: string, data: UpdateInput) {
+ try {
+ const updated = await updateApprovalTemplate(id, data)
+ return { success: true, data: updated }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// ----------------------------------------------------
+// Duplicate template
+// ----------------------------------------------------
+export async function duplicateApprovalTemplate(
+ id: string,
+ newName: string,
+ createdBy: number,
+): Promise<{ success: boolean; error?: string; data?: ApprovalTemplate }> {
+ try {
+ const existing = await getApprovalTemplate(id)
+ if (!existing) return { success: false, error: '템플릿을 찾을 수 없습니다.' }
+
+ // 새 템플릿 생성
+ const duplicated = await createApprovalTemplate({
+ name: newName,
+ subject: existing.subject,
+ content: existing.content,
+ category: existing.category ?? undefined,
+ description: existing.description ? `${existing.description} (복사본)` : undefined,
+ createdBy,
+ variables: existing.variables?.map((v) => ({
+ variableName: v.variableName,
+ variableType: v.variableType,
+ defaultValue: v.defaultValue,
+ description: v.description,
+ })),
+ })
+
+ return { success: true, data: duplicated }
+ } catch (error) {
+ return { success: false, error: error instanceof Error ? error.message : '복제에 실패했습니다.' }
+ }
+}
+
+// ----------------------------------------------------
+// Delete (soft delete X -> 실제 삭제)
+// ----------------------------------------------------
+export async function deleteApprovalTemplate(id: string): Promise<{ success: boolean; error?: string }> {
+ try {
+ await db.delete(approvalTemplates).where(eq(approvalTemplates.id, id));
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '삭제에 실패했습니다.',
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/approval-template/table/approval-template-table-columns.tsx b/lib/approval-template/table/approval-template-table-columns.tsx
new file mode 100644
index 00000000..8447c7d2
--- /dev/null
+++ b/lib/approval-template/table/approval-template-table-columns.tsx
@@ -0,0 +1,165 @@
+"use client"
+
+import * as React from 'react';
+import { type ColumnDef } from '@tanstack/react-table';
+import { MoreHorizontal, Copy, Trash, Eye } from 'lucide-react';
+import Link from 'next/link';
+
+import { Checkbox } from '@/components/ui/checkbox';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { formatDate } from '@/lib/utils';
+import { DataTableColumnHeaderSimple } from '@/components/data-table/data-table-column-simple-header';
+import { type ApprovalTemplate } from '@/lib/approval-template/service';
+import { type DataTableRowAction } from '@/types/table';
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ApprovalTemplate> | null>>
+}
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ApprovalTemplate>[] {
+ return [
+ {
+ id: 'select',
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && 'indeterminate')
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: 'name',
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="템플릿 이름" />,
+ cell: ({ row: _row }) => {
+ const t = _row.original;
+ return (
+ <div className="flex flex-col gap-1">
+ <span className="font-medium">{t.name}</span>
+ {t.description && (
+ <div className="text-xs text-muted-foreground line-clamp-2">{t.description}</div>
+ )}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ size: 220,
+ },
+ {
+ accessorKey: 'subject',
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="제목" />,
+ cell: ({ getValue }) => {
+ const subject = getValue() as string;
+ return <div className="text-sm text-muted-foreground">{subject}</div>;
+ },
+ enableSorting: true,
+ size: 250,
+ },
+ {
+ accessorKey: 'category',
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="카테고리" />,
+ cell: ({ getValue }) => {
+ const category = getValue() as string | null;
+ if (!category) return <div>-</div>;
+ return <Badge variant="outline">{category}</Badge>;
+ },
+ enableSorting: true,
+ size: 120,
+ },
+ {
+ accessorKey: 'createdAt',
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />,
+ cell: ({ cell }) => {
+ const date = cell.getValue() as Date;
+ return <div className="text-sm text-muted-foreground">{formatDate(date)}</div>;
+ },
+ enableSorting: true,
+ size: 200,
+ },
+ {
+ accessorKey: 'updatedAt',
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />,
+ cell: ({ cell }) => {
+ const date = cell.getValue() as Date;
+ return <div className="text-sm text-muted-foreground">{formatDate(date)}</div>;
+ },
+ enableSorting: true,
+ size: 200,
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => {
+ const template = row.original;
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <MoreHorizontal className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem asChild>
+ <Link href={`/evcp/approval/template/${template.id}`}>
+ <Eye className="mr-2 size-4" aria-hidden="true" />
+ 상세 보기
+ </Link>
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: 'duplicate', row });
+ }}
+ >
+ <Copy className="mr-2 size-4" aria-hidden="true" />
+ 복제하기
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: 'delete', row });
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제하기
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 80,
+ },
+ ];
+} \ No newline at end of file
diff --git a/lib/approval-template/table/approval-template-table-toolbar-actions.tsx b/lib/approval-template/table/approval-template-table-toolbar-actions.tsx
new file mode 100644
index 00000000..08aba97a
--- /dev/null
+++ b/lib/approval-template/table/approval-template-table-toolbar-actions.tsx
@@ -0,0 +1,114 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Plus, Trash } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { type ApprovalTemplate } from "@/lib/approval-template/service"
+import { toast } from "sonner"
+import { DeleteApprovalTemplateDialog } from "./delete-approval-template-dialog"
+
+interface ApprovalTemplateTableToolbarActionsProps {
+ table: Table<ApprovalTemplate>
+ onCreateTemplate: () => void
+}
+
+export function ApprovalTemplateTableToolbarActions({
+ table,
+ onCreateTemplate,
+}: ApprovalTemplateTableToolbarActionsProps) {
+ const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedTemplates = selectedRows.map((row) => row.original)
+
+ // CSV 내보내기
+ const exportToCsv = React.useCallback(() => {
+ const headers = [
+ "이름",
+ "제목",
+ "카테고리",
+ "생성일",
+ "수정일",
+ ]
+
+ const csvData = [
+ headers,
+ ...table.getFilteredRowModel().rows.map((row) => {
+ const t = row.original
+ return [
+ t.name,
+ t.subject,
+ t.category ?? "-",
+ new Date(t.createdAt).toLocaleDateString("ko-KR"),
+ new Date(t.updatedAt).toLocaleDateString("ko-KR"),
+ ]
+ }),
+ ]
+
+ const csvContent = csvData
+ .map((row) => row.map((field) => `"${field}"`).join(","))
+ .join("\n")
+
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
+ const link = document.createElement("a")
+
+ if (link.download !== undefined) {
+ const url = URL.createObjectURL(blob)
+ link.setAttribute("href", url)
+ link.setAttribute(
+ "download",
+ `approval_templates_${new Date().toISOString().split("T")[0]}.csv`,
+ )
+ link.style.visibility = "hidden"
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ }
+
+ toast.success("템플릿 목록이 CSV로 내보내졌습니다.")
+ }, [table])
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 새 템플릿 버튼 */}
+ <Button variant="default" size="sm" onClick={onCreateTemplate}>
+ <Plus className="mr-2 size-4" aria-hidden="true" />
+ 새 템플릿
+ </Button>
+
+ {/* CSV 내보내기 */}
+ <Button variant="outline" size="sm" onClick={exportToCsv}>
+ <Download className="mr-2 size-4" aria-hidden="true" />
+ 내보내기
+ </Button>
+
+ {/* 일괄 삭제 */}
+ {selectedTemplates.length > 0 && (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowDeleteDialog(true)}
+ className="text-destructive hover:text-destructive"
+ >
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({selectedTemplates.length})
+ </Button>
+
+ <DeleteApprovalTemplateDialog
+ open={showDeleteDialog}
+ onOpenChange={setShowDeleteDialog}
+ templates={selectedTemplates}
+ showTrigger={false}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ setShowDeleteDialog(false)
+ }}
+ />
+ </>
+ )}
+ </div>
+ )
+}
diff --git a/lib/approval-template/table/approval-template-table.tsx b/lib/approval-template/table/approval-template-table.tsx
new file mode 100644
index 00000000..d7f0e478
--- /dev/null
+++ b/lib/approval-template/table/approval-template-table.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import * as React from 'react';
+import { useDataTable } from '@/hooks/use-data-table';
+import { DataTable } from '@/components/data-table/data-table';
+import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar';
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from '@/types/table';
+
+import { getColumns } from './approval-template-table-columns';
+import { getApprovalTemplateList } from '../service';
+import { type ApprovalTemplate } from '../service';
+import { ApprovalTemplateTableToolbarActions } from './approval-template-table-toolbar-actions';
+import { CreateApprovalTemplateSheet } from './create-approval-template-sheet';
+import { UpdateApprovalTemplateSheet } from './update-approval-template-sheet';
+import { DuplicateApprovalTemplateSheet } from './duplicate-approval-template-sheet';
+import { DeleteApprovalTemplateDialog } from './delete-approval-template-dialog';
+
+interface ApprovalTemplateTableProps {
+ promises: Promise<[
+ Awaited<ReturnType<typeof getApprovalTemplateList>>,
+ ]>;
+}
+
+export function ApprovalTemplateTable({ promises }: ApprovalTemplateTableProps) {
+ const [{ data, pageCount }] = React.use(promises);
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<ApprovalTemplate> | null>(null);
+
+ const [showCreateSheet, setShowCreateSheet] = React.useState(false);
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ );
+
+ // 기본 & 고급 필터 필드 (추후 확장 가능)
+ const filterFields: DataTableFilterField<ApprovalTemplate>[] = [];
+ const advancedFilterFields: DataTableAdvancedFilterField<ApprovalTemplate>[] = [
+ {
+ id: 'name',
+ label: '템플릿 이름',
+ type: 'text',
+ },
+ {
+ id: 'subject',
+ label: '제목',
+ type: 'text',
+ },
+ {
+ id: 'category',
+ label: '카테고리',
+ type: 'text',
+ },
+ {
+ id: 'createdAt',
+ label: '생성일',
+ type: 'date',
+ },
+ {
+ id: 'updatedAt',
+ label: '수정일',
+ type: 'date',
+ },
+ ];
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: 'updatedAt', desc: true }],
+ columnPinning: { right: ['actions'] },
+ },
+ getRowId: (row) => String(row.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ApprovalTemplateTableToolbarActions
+ table={table}
+ onCreateTemplate={() => setShowCreateSheet(true)}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 새 템플릿 생성 Sheet */}
+ <CreateApprovalTemplateSheet
+ open={showCreateSheet}
+ onOpenChange={setShowCreateSheet}
+ />
+
+ {/* 템플릿 수정 Sheet */}
+ <UpdateApprovalTemplateSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ template={rowAction?.type === "update" ? rowAction.row.original : null}
+ />
+
+ {/* 템플릿 복제 Sheet */}
+ <DuplicateApprovalTemplateSheet
+ open={rowAction?.type === "duplicate"}
+ onOpenChange={() => setRowAction(null)}
+ template={rowAction?.type === "duplicate" ? rowAction.row.original : null}
+ />
+
+ {/* 템플릿 삭제 Dialog */}
+ <DeleteApprovalTemplateDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ templates={rowAction?.type === "delete" ? [rowAction.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ setRowAction(null)
+ // 테이블 새로고침은 server action에서 자동으로 처리됨
+ }}
+ />
+ </>
+ );
+} \ No newline at end of file
diff --git a/lib/approval-template/table/create-approval-template-sheet.tsx b/lib/approval-template/table/create-approval-template-sheet.tsx
new file mode 100644
index 00000000..7e899175
--- /dev/null
+++ b/lib/approval-template/table/create-approval-template-sheet.tsx
@@ -0,0 +1,174 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { createApprovalTemplate } from "../service"
+
+const createSchema = z.object({
+ name: z.string().min(1, "이름은 필수입니다").max(100, "100자 이하"),
+ subject: z.string().min(1, "제목은 필수입니다").max(200, "200자 이하"),
+ category: z.string().optional(),
+ description: z.string().optional(),
+})
+
+type CreateSchema = z.infer<typeof createSchema>
+
+interface CreateApprovalTemplateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {}
+
+export function CreateApprovalTemplateSheet({ ...props }: CreateApprovalTemplateSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ const form = useForm<CreateSchema>({
+ resolver: zodResolver(createSchema),
+ defaultValues: {
+ name: "",
+ subject: "",
+ category: undefined,
+ description: "",
+ },
+ })
+
+ function onSubmit(values: CreateSchema) {
+ startTransition(async () => {
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ const defaultContent = `<p>{{content}}</p>`
+
+ try {
+ const template = await createApprovalTemplate({
+ name: values.name,
+ subject: values.subject,
+ content: defaultContent,
+ category: values.category || undefined,
+ description: values.description || undefined,
+ createdBy: Number(session.user.id),
+ variables: [],
+ })
+
+ toast.success("템플릿이 생성되었습니다")
+ props.onOpenChange?.(false)
+
+ router.push(`/evcp/approval/template/${template.id}`)
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "생성에 실패했습니다")
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>새 템플릿 생성</SheetTitle>
+ <SheetDescription>새로운 결재 템플릿을 생성합니다.</SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿 이름</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 견적 승인 요청" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="subject"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제목</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 견적 승인 요청" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>카테고리 (선택)</FormLabel>
+ <FormControl>
+ <Input placeholder="카테고리" {...field} />
+ </FormControl>
+ <FormDescription>카테고리를 입력하지 않으면 미분류로 저장됩니다.</FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명 (선택)</FormLabel>
+ <FormControl>
+ <Input placeholder="설명" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />}
+ 생성 후 편집하기
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/approval-template/table/delete-approval-template-dialog.tsx b/lib/approval-template/table/delete-approval-template-dialog.tsx
new file mode 100644
index 00000000..9fa0a078
--- /dev/null
+++ b/lib/approval-template/table/delete-approval-template-dialog.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { type ApprovalTemplate } from "@/lib/approval-template/service"
+
+import { deleteApprovalTemplate } from "../service"
+
+interface DeleteApprovalTemplateDialogProps extends React.ComponentPropsWithRef<typeof Dialog> {
+ templates: ApprovalTemplate[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteApprovalTemplateDialog({
+ templates,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteApprovalTemplateDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+
+ const isMultiple = templates.length > 1
+ const templateName = isMultiple ? `${templates.length}개 템플릿` : templates[0]?.name
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ if (templates.length === 0) return
+
+ // 여러 개면 순차 삭제 (간단히 처리)
+ for (const t of templates) {
+ const result = await deleteApprovalTemplate(t.id)
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+ }
+
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ toast.success(
+ isMultiple ? `${templates.length}개 템플릿이 삭제되었습니다` : "템플릿이 삭제되었습니다",
+ )
+
+ // 새로고침으로 반영
+ window.location.reload()
+ })
+ }
+
+ return (
+ <Dialog {...props}>
+ {showTrigger && (
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="text-destructive hover:text-destructive"
+ >
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ )}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>템플릿 삭제 확인</DialogTitle>
+ <DialogDescription>
+ 정말로 <strong>{templateName}</strong>을(를) 삭제하시겠습니까?
+ {!isMultiple && (
+ <>
+ <br />이 작업은 되돌릴 수 없습니다.
+ </>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ {templates.length > 0 && (
+ <div className="rounded-lg bg-muted p-3 max-h-40 overflow-y-auto">
+ <h4 className="text-sm font-medium mb-2">삭제될 템플릿</h4>
+ <div className="space-y-1">
+ {templates.map((t) => (
+ <div key={t.id} className="text-xs text-muted-foreground">
+ <div className="font-medium">{t.name}</div>
+ <div>ID: <code className="bg-background px-1 rounded">{t.id}</code></div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => props.onOpenChange?.(false)}
+ disabled={isDeletePending}
+ >
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />}
+ {isMultiple ? `${templates.length}개 삭제` : "삭제"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/approval-template/table/duplicate-approval-template-sheet.tsx b/lib/approval-template/table/duplicate-approval-template-sheet.tsx
new file mode 100644
index 00000000..e49000ff
--- /dev/null
+++ b/lib/approval-template/table/duplicate-approval-template-sheet.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+import { useRouter } from "next/navigation"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { type ApprovalTemplate } from "@/lib/approval-template/service"
+import { duplicateApprovalTemplate } from "../service"
+import { useSession } from "next-auth/react"
+
+const duplicateSchema = z.object({
+ name: z.string().min(1, "템플릿 이름은 필수입니다").max(100, "100자 이내여야 합니다"),
+})
+
+type DuplicateSchema = z.infer<typeof duplicateSchema>
+
+interface DuplicateApprovalTemplateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ template: ApprovalTemplate | null
+}
+
+export function DuplicateApprovalTemplateSheet({ template, ...props }: DuplicateApprovalTemplateSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ const form = useForm<DuplicateSchema>({
+ resolver: zodResolver(duplicateSchema),
+ defaultValues: { name: "" },
+ })
+
+ React.useEffect(() => {
+ if (template) {
+ form.reset({ name: `${template.name} (복사본)` })
+ }
+ }, [template, form])
+
+ function onSubmit(values: DuplicateSchema) {
+ startTransition(async () => {
+ if (!template) return
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ const { success, error, data } = await duplicateApprovalTemplate(
+ template.id,
+ values.name,
+ Number(session.user.id),
+ )
+
+ if (!success || error) {
+ toast.error(error ?? "복제에 실패했습니다")
+ return
+ }
+
+ toast.success("템플릿이 복제되었습니다")
+ props.onOpenChange?.(false)
+
+ // 상세 페이지로 이동 (id 기반)
+ if (data?.id) {
+ router.push(`/evcp/approval/template/${data.id}`)
+ } else {
+ window.location.reload()
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>템플릿 복제</SheetTitle>
+ <SheetDescription>기존 템플릿을 복사하여 새로운 템플릿을 생성합니다.</SheetDescription>
+ </SheetHeader>
+
+ {template && (
+ <div className="rounded-lg bg-muted p-3">
+ <h4 className="text-sm font-medium mb-2">원본 템플릿</h4>
+ <div className="space-y-1 text-xs text-muted-foreground">
+ <div>이름: {template.name}</div>
+ <div>ID: <code className="bg-background px-1 rounded">{template.id}</code></div>
+ <div>카테고리: {template.category ?? "-"}</div>
+ </div>
+ </div>
+ )}
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>새 템플릿 이름</FormLabel>
+ <FormControl>
+ <Input placeholder="복제된 템플릿 이름" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />}
+ 복제하기
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/approval-template/table/update-approval-template-sheet.tsx b/lib/approval-template/table/update-approval-template-sheet.tsx
new file mode 100644
index 00000000..05f4069c
--- /dev/null
+++ b/lib/approval-template/table/update-approval-template-sheet.tsx
@@ -0,0 +1,23 @@
+"use client"
+
+import * as React from "react"
+import { Sheet } from "@/components/ui/sheet"
+import { type ApprovalTemplate } from "@/lib/approval-template/service"
+
+interface UpdateApprovalTemplateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ template: ApprovalTemplate | null
+}
+
+export function UpdateApprovalTemplateSheet({ template, ...props }: UpdateApprovalTemplateSheetProps) {
+ // 현재 프로젝트 요구사항 범위 내에서는 상세 편집 페이지 또는 별도 에디터가 준비되지 않았으므로
+ // 업데이트 시트는 추후 구현합니다. (이메일 템플릿에서는 사용되지 않아 주석 처리되어 있었음)
+ // 이를 사용하려 할 경우 안내 문구만 표시합니다.
+
+ return (
+ <Sheet {...props}>
+ <div className="p-6 text-sm text-muted-foreground">
+ 템플릿 편집 기능은 아직 구현되지 않았습니다.
+ </div>
+ </Sheet>
+ )
+}
diff --git a/lib/approval-template/validations.ts b/lib/approval-template/validations.ts
new file mode 100644
index 00000000..3b60e478
--- /dev/null
+++ b/lib/approval-template/validations.ts
@@ -0,0 +1,27 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from 'nuqs/server';
+import { z } from 'zod';
+
+import { getFiltersStateParser, getSortingStateParser } from '@/lib/parsers';
+import { approvalTemplates } from '@/db/schema/knox/approvals';
+
+export const SearchParamsApprovalTemplateCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(['advancedTable', 'floatingBar'])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof approvalTemplates>().withDefault([
+ { id: 'updatedAt', desc: true },
+ ]),
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(['and', 'or']).withDefault('and'),
+ search: parseAsString.withDefault(''),
+});
+
+export type GetApprovalTemplateSchema = Awaited<
+ ReturnType<typeof SearchParamsApprovalTemplateCache.parse>
+>; \ No newline at end of file
diff --git a/lib/knox-api/approval/service.ts b/lib/knox-api/approval/service.ts
index 6ef1b1f6..0bd817a6 100644
--- a/lib/knox-api/approval/service.ts
+++ b/lib/knox-api/approval/service.ts
@@ -1,7 +1,7 @@
"use server"
import db from '@/db/db';
import { ApprovalLine } from "./approval";
-import { approval } from '@/db/schema/knox/approvals';
+import { approvalLogs } from '@/db/schema/knox/approvals';
import { eq, and } from 'drizzle-orm';
// ========== 데이터베이스 서비스 함수들 ==========
@@ -21,7 +21,7 @@ export async function saveApprovalToDatabase(
aplns: ApprovalLine[]
): Promise<void> {
try {
- await db.insert(approval).values({
+ await db.insert(approvalLogs).values({
apInfId,
userId,
epId,
@@ -51,12 +51,12 @@ export async function updateApprovalStatus(
): Promise<void> {
try {
await db
- .update(approval)
+ .update(approvalLogs)
.set({
status,
updatedAt: new Date(),
})
- .where(eq(approval.apInfId, apInfId));
+ .where(eq(approvalLogs.apInfId, apInfId));
} catch (error) {
console.error('결재 상태 업데이트 실패:', error);
throw new Error('결재 상태를 업데이트하는 중 오류가 발생했습니다.');
@@ -69,15 +69,15 @@ export async function updateApprovalStatus(
export async function getApprovalFromDatabase(
apInfId: string,
includeDeleted: boolean = false
-): Promise<typeof approval.$inferSelect | null> {
+): Promise<typeof approvalLogs.$inferSelect | null> {
try {
const whereCondition = includeDeleted
- ? eq(approval.apInfId, apInfId)
- : and(eq(approval.apInfId, apInfId), eq(approval.isDeleted, false));
+ ? eq(approvalLogs.apInfId, apInfId)
+ : and(eq(approvalLogs.apInfId, apInfId), eq(approvalLogs.isDeleted, false));
const result = await db
.select()
- .from(approval)
+ .from(approvalLogs)
.where(whereCondition)
.limit(1);
@@ -96,17 +96,17 @@ export async function getApprovalsByUser(
limit: number = 50,
offset: number = 0,
includeDeleted: boolean = false
-): Promise<typeof approval.$inferSelect[]> {
+): Promise<typeof approvalLogs.$inferSelect[]> {
try {
const whereCondition = includeDeleted
- ? eq(approval.userId, userId)
- : and(eq(approval.userId, userId), eq(approval.isDeleted, false));
+ ? eq(approvalLogs.userId, userId)
+ : and(eq(approvalLogs.userId, userId), eq(approvalLogs.isDeleted, false));
const result = await db
.select()
- .from(approval)
+ .from(approvalLogs)
.where(whereCondition)
- .orderBy(approval.createdAt)
+ .orderBy(approvalLogs.createdAt)
.limit(limit)
.offset(offset);
@@ -125,12 +125,12 @@ export async function deleteApprovalFromDatabase(
): Promise<void> {
try {
await db
- .update(approval)
+ .update(approvalLogs)
.set({
isDeleted: true,
updatedAt: new Date(),
})
- .where(eq(approval.apInfId, apInfId));
+ .where(eq(approvalLogs.apInfId, apInfId));
} catch (error) {
console.error('결재 데이터 삭제 실패:', error);
throw new Error('결재 데이터를 삭제하는 중 오류가 발생했습니다.');
diff --git a/lib/knox-api/organization-service.ts b/lib/knox-api/organization-service.ts
new file mode 100644
index 00000000..c90b00f7
--- /dev/null
+++ b/lib/knox-api/organization-service.ts
@@ -0,0 +1,95 @@
+'use server';
+
+import db from '@/db/db';
+import { organization as organizationTable } from '@/db/schema/knox/organization';
+import { count, ilike, isNotNull } from 'drizzle-orm';
+
+
+// ----------------------------------------------------
+// 조직 관리자 검색 함수
+// ----------------------------------------------------
+
+interface SearchOrganizationsForManagerInput {
+ search: string;
+ page: number;
+ perPage: number;
+}
+
+interface SearchOrganizationsForManagerResult {
+ data: Array<{
+ id: string;
+ departmentCode: string;
+ departmentName: string;
+ managerId: string;
+ managerName: string;
+ managerTitle: string;
+ companyCode: string;
+ companyName: string;
+ }>;
+ total: number;
+ pageCount: number;
+}
+
+export async function searchOrganizationsForManager(
+ input: SearchOrganizationsForManagerInput
+): Promise<SearchOrganizationsForManagerResult> {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 검색 조건 구성
+ const searchTerm = `%${input.search}%`;
+
+ const results = await db
+ .select({
+ id: organizationTable.departmentCode,
+ departmentCode: organizationTable.departmentCode,
+ departmentName: organizationTable.departmentName,
+ managerId: organizationTable.managerId,
+ managerName: organizationTable.managerName,
+ managerTitle: organizationTable.managerTitle,
+ companyCode: organizationTable.companyCode,
+ companyName: organizationTable.companyName,
+ })
+ .from(organizationTable)
+ .where(
+ // 관리자가 있고, 부서명 또는 관리자명으로 검색
+ isNotNull(organizationTable.managerId)
+ )
+ .having(
+ // 부서명 또는 관리자명으로 검색
+ ilike(organizationTable.departmentName, searchTerm) ||
+ ilike(organizationTable.managerName, searchTerm)
+ )
+ .orderBy(organizationTable.departmentName)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(organizationTable)
+ .where(
+ isNotNull(organizationTable.managerId)
+ )
+ .having(
+ ilike(organizationTable.departmentName, searchTerm) ||
+ ilike(organizationTable.managerName, searchTerm)
+ );
+
+ const total = totalResult[0]?.count ?? 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return {
+ data: results as Array<{
+ id: string;
+ departmentCode: string;
+ departmentName: string;
+ managerId: string;
+ managerName: string;
+ managerTitle: string;
+ companyCode: string;
+ companyName: string;
+ }>,
+ total,
+ pageCount,
+ };
+} \ No newline at end of file
diff --git a/lib/nonsap-sync/procurement-sync-service.ts b/lib/nonsap-sync/procurement-sync-service.ts
index 1f719526..c8365a1c 100644
--- a/lib/nonsap-sync/procurement-sync-service.ts
+++ b/lib/nonsap-sync/procurement-sync-service.ts
@@ -25,7 +25,7 @@ const logger = {
async function testOracleConnection(): Promise<boolean> {
try {
const result = await oracleKnex.raw('SELECT 1 FROM DUAL');
- return result.rows && result.rows.length > 0;
+ return result && result.length > 0;
} catch (error) {
logger.error('Oracle DB 연결 테스트 실패:', error);
return false;
@@ -46,7 +46,7 @@ async function syncPaymentTerms(): Promise<void> {
WHERE stc.cd = 'PAYT'
`);
- const paymentTermsData = oracleData.rows || [];
+ const paymentTermsData = oracleData || [];
logger.info(`Oracle에서 ${paymentTermsData.length}개의 지불조건 데이터 조회`);
if (paymentTermsData.length === 0) {
@@ -64,7 +64,7 @@ async function syncPaymentTerms(): Promise<void> {
// 업데이트할 데이터 준비
const upsertData = paymentTermsData.map((item: { CODE: string; DESCRIPTION: string }) => ({
code: item.CODE,
- description: item.DESCRIPTION,
+ description: item.DESCRIPTION || 'NONSAP에서 설명 기입 요망',
isActive: true // 기본값 true
}));
@@ -112,7 +112,7 @@ async function syncIncoterms(): Promise<void> {
WHERE stc.cd = 'INCO'
`);
- const incotermsData = oracleData.rows || [];
+ const incotermsData = oracleData || [];
logger.info(`Oracle에서 ${incotermsData.length}개의 인코텀즈 데이터 조회`);
if (incotermsData.length === 0) {
@@ -130,7 +130,7 @@ async function syncIncoterms(): Promise<void> {
// 업데이트할 데이터 준비
const upsertData = incotermsData.map((item: { CODE: string; DESCRIPTION: string }) => ({
code: item.CODE,
- description: item.DESCRIPTION,
+ description: item.DESCRIPTION || 'NONSAP에서 설명 기입 요망',
isActive: true // 기본값 true
}));
@@ -181,7 +181,7 @@ async function syncPlaceOfShipping(): Promise<void> {
ORDER BY cd.CD asc, cdnm.CDNM asc
`);
- const placeOfShippingData = oracleData.rows || [];
+ const placeOfShippingData = oracleData || [];
logger.info(`Oracle에서 ${placeOfShippingData.length}개의 선적/하역지 데이터 조회`);
if (placeOfShippingData.length === 0) {
@@ -199,7 +199,7 @@ async function syncPlaceOfShipping(): Promise<void> {
// 업데이트할 데이터 준비 (isActive = "Y"인 경우 true, 그 외는 기본값 true)
const upsertData = placeOfShippingData.map((item: { CODE: string; DESCRIPTION: string; ISACTIVE: string }) => ({
code: item.CODE,
- description: item.DESCRIPTION,
+ description: item.DESCRIPTION || 'NONSAP에서 설명 기입 요망',
isActive: item.ISACTIVE === 'Y' ? true : true // 기본값 true
}));
diff --git a/lib/pdftron/serverSDK/createBasicContractPdf.ts b/lib/pdftron/serverSDK/createBasicContractPdf.ts
index 706508e6..6b7a4baf 100644
--- a/lib/pdftron/serverSDK/createBasicContractPdf.ts
+++ b/lib/pdftron/serverSDK/createBasicContractPdf.ts
@@ -1,17 +1,16 @@
-const { PDFNet } = require("@pdftron/pdfnet-node");
-const fs = require('fs').promises;
-const path = require('path');
+import { PDFNet } from "@pdftron/pdfnet-node";
+import { promises as fs } from "fs";
import { file as tmpFile } from "tmp-promise";
type CreateBasicContractPdf = (
templateBuffer: Buffer,
templateData: {
- [key: string]: any;
+ [key: string]: unknown;
}
) => Promise<{
result: boolean;
buffer?: ArrayBuffer;
- error?: any;
+ error?: unknown;
}>;
export const createBasicContractPdf: CreateBasicContractPdf = async (
diff --git a/lib/soap/ecc-mapper.ts b/lib/soap/ecc-mapper.ts
new file mode 100644
index 00000000..030d6973
--- /dev/null
+++ b/lib/soap/ecc-mapper.ts
@@ -0,0 +1,429 @@
+import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils';
+import db from '@/db/db';
+import {
+ procurementRfqs,
+ prItems,
+ procurementRfqDetails,
+} from '@/db/schema/procurementRFQ';
+import {
+ PR_INFORMATION_T_BID_HEADER,
+ PR_INFORMATION_T_BID_ITEM,
+} from '@/db/schema/ECC/ecc';
+import { users } from '@/db/schema';
+import { employee } from '@/db/schema/knox/employee';
+import { eq, inArray } from 'drizzle-orm';
+
+// NON-SAP 데이터 처리
+import { oracleKnex } from '@/lib/oracle-db/db';
+import { findUserIdByEmployeeNumber } from '../users/knox-service';
+
+// ECC 데이터 타입 정의
+export type ECCBidHeader = typeof PR_INFORMATION_T_BID_HEADER.$inferInsert;
+export type ECCBidItem = typeof PR_INFORMATION_T_BID_ITEM.$inferInsert;
+
+// 비즈니스 테이블 데이터 타입 정의
+export type ProcurementRfqData = typeof procurementRfqs.$inferInsert;
+export type PrItemData = typeof prItems.$inferInsert;
+export type ProcurementRfqDetailData =
+ typeof procurementRfqDetails.$inferInsert;
+
+/**
+ * 시리즈 판단: 관련 PR 아이템들의 PSPID를 기반으로 결정
+ * - 아이템이 1개 이하: null
+ * - 아이템이 2개 이상이고 PSPID가 모두 동일: "SS"
+ * - 아이템이 2개 이상이고 PSPID가 서로 다름: "||"
+ */
+function computeSeriesFromItems(items: ECCBidItem[]): string | null {
+ if (items.length <= 1) return null;
+
+ const normalize = (v: unknown): string | null => {
+ if (typeof v !== 'string') return null;
+ const trimmed = v.trim();
+ return trimmed.length > 0 ? trimmed : null;
+ };
+
+ const uniquePspids = new Set<string | null>(
+ items.map((it) => normalize(it.PSPID as string | null | undefined))
+ );
+
+ return uniquePspids.size === 1 ? 'SS' : '||';
+}
+
+/**
+ * PERNR(사번)을 기준으로 사용자 ID를 찾는 함수
+ */
+async function findUserIdByPernr(pernr: string): Promise<number | null> {
+ try {
+ debugLog('PERNR로 사용자 ID 찾기 시작', { pernr });
+
+ // 현재 users 테이블에 사번을 따로 저장하지 않으므로 knox 기준으로 사번 --> epId --> user.id 순으로 찾기
+ // 1. employee 테이블에서 employeeNumber로 epId 찾기
+ const employeeResult = await db
+ .select({ epId: employee.epId })
+ .from(employee)
+ .where(eq(employee.employeeNumber, pernr))
+ .limit(1);
+
+ if (employeeResult.length === 0) {
+ debugError('사번에 해당하는 직원 정보를 찾을 수 없음', { pernr });
+ return null;
+ }
+
+ const epId = employeeResult[0].epId;
+ debugLog('직원 epId 찾음', { pernr, epId });
+
+ // 2. users 테이블에서 epId로 사용자 ID 찾기
+ const userResult = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.epId, epId))
+ .limit(1);
+
+ if (userResult.length === 0) {
+ debugError('epId에 해당하는 사용자 정보를 찾을 수 없음', { epId });
+ return null;
+ }
+
+ const userId = userResult[0].id;
+ debugSuccess('사용자 ID 찾음', { pernr, epId, userId });
+ return userId;
+ } catch (error) {
+ debugError('사용자 ID 찾기 중 오류 발생', { pernr, error });
+ return null;
+ }
+}
+
+
+
+/**
+ * 담당자 찾는 함수 (Non-SAP 데이터를 기준으로 찾음)
+ * Puchasing Group을 CMCTB_CD 에서 CD_CLF='MMA070' 조건으로, CD=EKGRP 조건으로 찾으면, USR_DF_CHAR_9 컬럼이 담당자 사번임. 기준으로 유저를 넣어줄 예정임
+ */
+async function findInChargeUserIdByEKGRP(EKGRP: string | null): Promise<number | null> {
+ try {
+ debugLog('담당자 찾기 시작', { EKGRP });
+ // NonSAP에서 담당자 사번 찾기
+
+ const result = await oracleKnex
+ .select('USR_DF_CHAR_9')
+ .from('CMCTB_CD')
+ .where('CD_CLF', 'MMA070')
+ .andWhere('CD', EKGRP)
+ .limit(1);
+
+ if (result.length === 0) {
+ debugError('담당자 찾기 중 오류 발생', { EKGRP });
+ return null;
+ }
+
+ const employeeNumber = result[0].USR_DF_CHAR_9;
+
+ // 임시 : Knox API 기준으로 사번에 해당하는 userId 찾기 (nonsap에서 제공하는 유저테이블로 변경 예정)
+ const userId = await findUserIdByEmployeeNumber(employeeNumber);
+
+ debugSuccess('담당자 찾음', { EKGRP, userId });
+ return userId;
+ } catch (error) {
+ debugError('담당자 찾기 중 오류 발생', { EKGRP, error });
+ return null;
+ }
+}
+
+// *****************************mapping functions*********************************
+
+/**
+ * ECC RFQ 헤더 데이터를 비즈니스 테이블로 매핑
+ */
+export async function mapECCRfqHeaderToBusiness(
+ eccHeader: ECCBidHeader
+): Promise<ProcurementRfqData> {
+ debugLog('ECC RFQ 헤더 매핑 시작', { anfnr: eccHeader.ANFNR });
+
+ // 날짜 파싱 (실패시 현재 Date 들어감)
+ let interfacedAt: Date = new Date();
+
+ if (eccHeader.ZRFQ_TRS_DT != null && eccHeader.ZRFQ_TRS_TM != null) {
+ try {
+ // SAP 날짜 형식 (YYYYMMDD) 파싱
+ const dateStr = eccHeader.ZRFQ_TRS_DT;
+ if (dateStr.length === 8) {
+ const year = parseInt(dateStr.substring(0, 4));
+ const month = parseInt(dateStr.substring(4, 6)) - 1; // 0-based
+ const day = parseInt(dateStr.substring(6, 8));
+ const hour = parseInt(eccHeader.ZRFQ_TRS_TM.substring(0, 2));
+ const minute = parseInt(eccHeader.ZRFQ_TRS_TM.substring(2, 4));
+ const second = parseInt(eccHeader.ZRFQ_TRS_TM.substring(4, 6));
+ interfacedAt = new Date(year, month, day, hour, minute, second);
+ }
+ } catch (error) {
+ debugError('날짜 파싱 오류', {
+ date: eccHeader.ZRFQ_TRS_DT,
+ time: eccHeader.ZRFQ_TRS_TM,
+ error,
+ });
+ }
+ }
+
+ // 담당자 찾기
+ const inChargeUserId = await findInChargeUserIdByEKGRP(eccHeader.EKGRP || null);
+
+ // 시리즈는 3가지의 케이스만 온다고 가정한다. (다른 케이스는 잘못된 것)
+ // 케이스 1. 한 RFQ에서 PR Item이 여러 개고, PSPID 값이 모두 같은 경우 => series 값은 "SS"
+ // 케이스 2. 한 RFQ에서 PR Item이 여러 개고, PSPID 값이 여러개인 경우 => series 값은 "||""
+ // 케이스 3. 한 RFQ에서 PR Item이 하나인 경우 => seires 값은 null
+ // 만약 위 케이스에 모두 속하지 않는 경우 케이스 3처럼 null 값을 넣는다.
+
+ // 매핑
+ const mappedData: ProcurementRfqData = {
+ rfqCode: eccHeader.ANFNR, // rfqCode=rfqNumber(ANFNR), 혼선이 있을 수 있으나 ECC에는 rfqCode라는 게 별도로 없음. rfqCode=rfqNumber
+ projectId: null, // PR 아이템 처리 후 업데이트. (로직은 추후 작성)
+ series: null, // PR 아이템 처리 후 업데이트. (로직은 추후 작성)
+ // itemGroup: null, // 대표 자재그룹: PR 아이템 처리 후 업데이트. (로직은 추후 작성)
+ itemCode: null, // 대표 자재코드: PR 아이템 처리 후 업데이트. (로직은 추후 작성)
+ itemName: null, // 대표 자재명: PR 아이템 처리 후 업데이트. (로직은 추후 작성)
+ dueDate: null, // eVCP에서 사용하는 컬럼이므로 불필요.
+ rfqSendDate: null, // eVCP에서 사용하는 컬럼이므로 불필요.
+ createdAt: interfacedAt,
+ status: 'RFQ Created', // eVCP에서 사용하는 컬럼, 기본값 RFQ Created 처리
+ rfqSealedYn: false, // eVCP에서 사용하는 컬럼, 기본값 false 처리
+ picCode: eccHeader.EKGRP || null, // Purchasing Group을 PIC로 사용 (구매측 임직원과 연계된 코드임)
+ remark: null, // remark 컬럼은 담당자 메모용으로 넣어줄 필요 없음.
+ sentBy: null, // 보내기 전의 RFQ를 대상으로 하므로 넣어줄 필요 없음.
+ createdBy: inChargeUserId || 1,
+ updatedBy: inChargeUserId || 1,
+ };
+
+ debugSuccess('ECC RFQ 헤더 매핑 완료', { anfnr: eccHeader.ANFNR });
+ return mappedData;
+}
+
+/**
+ * ECC RFQ 아이템 데이터를 비즈니스 테이블로 매핑
+ */
+export function mapECCRfqItemToBusiness(
+ eccItem: ECCBidItem,
+ rfqId: number
+): PrItemData {
+ debugLog('ECC RFQ 아이템 매핑 시작', {
+ anfnr: eccItem.ANFNR,
+ anfps: eccItem.ANFPS,
+ });
+
+ // 날짜 파싱
+ let deliveryDate: Date | null = null;
+ if (eccItem.LFDAT) {
+ try {
+ const dateStr = eccItem.LFDAT;
+ if (dateStr.length === 8) {
+ const year = parseInt(dateStr.substring(0, 4));
+ const month = parseInt(dateStr.substring(4, 6)) - 1;
+ const day = parseInt(dateStr.substring(6, 8));
+ deliveryDate = new Date(year, month, day);
+ }
+ } catch (error) {
+ debugError('아이템 날짜 파싱 오류', { date: eccItem.LFDAT, error });
+ }
+ }
+
+ // TODO: 시리즈인 경우 EBELP(Series PO Item Seq) 를 참조하는 로직 필요? 이 컬럼의 의미 확인 필요
+
+
+ const mappedData: PrItemData = {
+ procurementRfqsId: rfqId, // PR Item의 부모 RFQ ID [ok]
+ rfqItem: eccItem.ANFPS || null, // itemNo [ok]
+ prItem: eccItem.BANPO || null, // ECC PR No [ok]
+ prNo: eccItem.BANFN || null, // ECC PR No [ok]
+ materialCode: eccItem.MATNR || null, // ECC Material Number [ok]
+ materialCategory: eccItem.MATKL || null, // ECC Material Group [ok]
+ acc: eccItem.SAKTO || null, // ECC G/L Account Number [ok]
+ materialDescription: eccItem.TXZ01 || null, // ECC Short Text [ok] // TODO: 자재 테이블 참조해서 자재명 넣어주기 ?
+ size: null, // ECC에서 해당 정보 없음 // TODO: 이시원 프로에게 확인
+ deliveryDate, // ECC PR Delivery Date (parsed)
+ quantity: eccItem.MENGE ? Number(eccItem.MENGE) : null, // ECC PR Quantity [ok]
+ uom: eccItem.MEINS || null, // ECC PR UOM [ok]
+ grossWeight: eccItem.BRGEW ? Number(eccItem.BRGEW) : null, // ECC PR Gross Weight [ok]
+ gwUom: eccItem.GEWEI || null, // ECC PR Gross Weight UOM [ok]
+ specNo: null, // ECC에서 해당 정보 없음, TODO: 이시원 프로 - material 참조해서 넣어주는건지, PR 마다 고유한건지 확인
+ specUrl: null, // ECC에서 해당 정보 없음, TODO: 이시원 프로에게 material 참조해서 넣어주는건지, PR 마다 고유한건지 확인
+ trackingNo: null, // TODO: 이시원 프로에게 확인 필요. I/F 정의서 어느 항목인지 추정 불가
+ majorYn: false, // 기본값 false 할당, 필요시 eVCP에서 수정
+ projectDef: eccItem.PSPID || null, // Project Key 로 처리. // TODO: 프로젝트 테이블 참조해 코드로 처리하기
+ projectSc: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기
+ projectKl: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기
+ projectLc: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기
+ projectDl: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기
+ remark: null, // remark 컬럼은 담당자 메모용으로 넣어줄 필요 없음.
+ };
+
+ debugSuccess('ECC RFQ 아이템 매핑 완료', {
+ rfqItem: eccItem.ANFPS,
+ materialCode: eccItem.MATNR,
+ });
+ return mappedData;
+}
+
+/**
+ * ECC 데이터를 비즈니스 테이블로 일괄 매핑 및 저장
+ */
+export async function mapAndSaveECCRfqData(
+ eccHeaders: ECCBidHeader[],
+ eccItems: ECCBidItem[]
+): Promise<{ success: boolean; message: string; processedCount: number }> {
+ debugLog('ECC 데이터 일괄 매핑 및 저장 시작', {
+ headerCount: eccHeaders.length,
+ itemCount: eccItems.length,
+ });
+
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1) 헤더별 관련 아이템 그룹핑 + 시리즈 계산 + 헤더 매핑을 병렬로 수행
+ const rfqGroups = await Promise.all(
+ eccHeaders.map(async (eccHeader) => {
+ const relatedItems = eccItems.filter((item) => item.ANFNR === eccHeader.ANFNR);
+ const series = computeSeriesFromItems(relatedItems);
+ const rfqData = await mapECCRfqHeaderToBusiness(eccHeader);
+ rfqData.series = series;
+ return { rfqCode: rfqData.rfqCode, rfqData, relatedItems };
+ })
+ );
+
+ const rfqRecords = rfqGroups.map((g) => g.rfqData);
+
+ // 2) RFQ 다건 삽입 (중복은 무시). 반환된 레코드로 일부 ID 매핑
+ const inserted = await tx
+ .insert(procurementRfqs)
+ .values(rfqRecords)
+ .onConflictDoNothing()
+ .returning({ id: procurementRfqs.id, rfqCode: procurementRfqs.rfqCode });
+
+ const rfqCodeToId = new Map<string, number>();
+ for (const row of inserted) {
+ if (row.rfqCode) {
+ rfqCodeToId.set(row.rfqCode, row.id);
+ }
+ }
+
+ // 3) 반환되지 않은 기존 RFQ 들의 ID 조회하여 매핑 보완
+ const allCodes = rfqRecords
+ .map((r) => r.rfqCode)
+ .filter((c): c is string => typeof c === 'string' && c.length > 0);
+ const missingCodes = allCodes.filter((c) => !rfqCodeToId.has(c));
+ if (missingCodes.length > 0) {
+ const existing = await tx
+ .select({ id: procurementRfqs.id, rfqCode: procurementRfqs.rfqCode })
+ .from(procurementRfqs)
+ .where(inArray(procurementRfqs.rfqCode, missingCodes));
+ for (const row of existing) {
+ if (row.rfqCode) {
+ rfqCodeToId.set(row.rfqCode, row.id);
+ }
+ }
+ }
+
+ // 4) 모든 아이템을 한 번에 생성할 데이터로 변환
+ const allItemsToInsert: PrItemData[] = [];
+ for (const group of rfqGroups) {
+ const rfqCode = group.rfqCode;
+ if (!rfqCode) continue;
+ const rfqId = rfqCodeToId.get(rfqCode);
+ if (!rfqId) {
+ debugError('RFQ ID 매핑 누락', { rfqCode });
+ throw new Error(`RFQ ID를 찾을 수 없습니다: ${rfqCode}`);
+ }
+
+ for (const eccItem of group.relatedItems) {
+ const itemData = mapECCRfqItemToBusiness(eccItem, rfqId);
+ allItemsToInsert.push(itemData);
+ }
+ }
+
+ // 5) 아이템 일괄 삽입 (chunk 처리로 파라미터 제한 회피)
+ const ITEM_CHUNK_SIZE = 1000;
+ for (let i = 0; i < allItemsToInsert.length; i += ITEM_CHUNK_SIZE) {
+ const chunk = allItemsToInsert.slice(i, i + ITEM_CHUNK_SIZE);
+ await tx.insert(prItems).values(chunk);
+ }
+
+ return { processedCount: rfqRecords.length };
+ });
+
+ debugSuccess('ECC 데이터 일괄 처리 완료', {
+ processedCount: result.processedCount,
+ });
+
+ return {
+ success: true,
+ message: `${result.processedCount}개의 RFQ 데이터가 성공적으로 처리되었습니다.`,
+ processedCount: result.processedCount,
+ };
+ } catch (error) {
+ debugError('ECC 데이터 처리 중 오류 발생', error);
+ return {
+ success: false,
+ message:
+ error instanceof Error
+ ? error.message
+ : '알 수 없는 오류가 발생했습니다.',
+ processedCount: 0,
+ };
+ }
+}
+
+/**
+ * ECC 데이터 유효성 검증
+ */
+export function validateECCRfqData(
+ eccHeaders: ECCBidHeader[],
+ eccItems: ECCBidItem[]
+): { isValid: boolean; errors: string[] } {
+ const errors: string[] = [];
+
+ // 헤더 데이터 검증
+ for (const header of eccHeaders) {
+ if (!header.ANFNR) {
+ errors.push(`필수 필드 누락: ANFNR (Bidding/RFQ Number)`);
+ }
+ if (!header.ZBSART) {
+ errors.push(
+ `필수 필드 누락: ZBSART (Bidding Type) - ANFNR: ${header.ANFNR}`
+ );
+ }
+ }
+
+ // 아이템 데이터 검증
+ for (const item of eccItems) {
+ if (!item.ANFNR) {
+ errors.push(
+ `필수 필드 누락: ANFNR (Bidding/RFQ Number) - Item: ${item.ANFPS}`
+ );
+ }
+ if (!item.ANFPS) {
+ errors.push(`필수 필드 누락: ANFPS (Item Number) - ANFNR: ${item.ANFNR}`);
+ }
+ if (!item.BANFN) {
+ errors.push(
+ `필수 필드 누락: BANFN (Purchase Requisition Number) - ANFNR: ${item.ANFNR}, ANFPS: ${item.ANFPS}`
+ );
+ }
+ if (!item.BANPO) {
+ errors.push(
+ `필수 필드 누락: BANPO (Item Number of Purchase Requisition) - ANFNR: ${item.ANFNR}, ANFPS: ${item.ANFPS}`
+ );
+ }
+ }
+
+ // 헤더와 아이템 간의 관계 검증
+ const headerAnfnrs = new Set(eccHeaders.map((h) => h.ANFNR));
+ const itemAnfnrs = new Set(eccItems.map((i) => i.ANFNR));
+
+ for (const anfnr of itemAnfnrs) {
+ if (!headerAnfnrs.has(anfnr)) {
+ errors.push(`아이템의 ANFNR이 헤더에 존재하지 않음: ${anfnr}`);
+ }
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ };
+}
diff --git a/lib/users/knox-service.ts b/lib/users/knox-service.ts
index d5453072..d6755022 100644
--- a/lib/users/knox-service.ts
+++ b/lib/users/knox-service.ts
@@ -4,6 +4,8 @@ import { unstable_cache } from "next/cache";
import db from "@/db/db";
import { organization } from "@/db/schema/knox/organization";
import { eq, and, asc } from "drizzle-orm";
+import { users } from "@/db/schema/users";
+import { employee } from "@/db/schema/knox/employee";
// 조직 트리 노드 타입
export interface DepartmentNode {
@@ -375,3 +377,38 @@ export async function getCurrentCompanyInfo(): Promise<{ code: string; name: str
name: "삼성중공업"
};
}
+
+// 사번으로 user Id 찾기
+export async function findUserIdByEmployeeNumber(employeeNumber: string): Promise<number | null> {
+
+ try {
+ // 1. 사번 기준으로 email 찾기
+ const userEmail = await db
+ .select({ email: employee.emailAddress })
+ .from(employee)
+ .where(eq(employee.employeeNumber, employeeNumber))
+ .limit(1);
+
+ if (userEmail.length === 0) {
+ console.error('사번에 해당하는 이메일 찾기 실패', { employeeNumber });
+ return null;
+ }
+
+ // 2. 이메일 기준으로 userId 찾기
+ const userId = await db
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.email, userEmail[0].email));
+
+ if (userId.length === 0) {
+ console.error('이메일에 해당하는 사용자 찾기 실패', { email: userEmail[0].email });
+ return null;
+ }
+
+ return userId[0].id;
+
+ } catch (error) {
+ console.error('사번에 해당하는 이메일 찾기 실패', { employeeNumber, error });
+ return null;
+ }
+} \ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 6d1047e8..ff8b68cf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -89,6 +89,7 @@
"@tiptap/extension-link": "^2.23.1",
"@tiptap/extension-list-item": "^2.23.1",
"@tiptap/extension-ordered-list": "^2.23.1",
+ "@tiptap/extension-placeholder": "^2.23.1",
"@tiptap/extension-subscript": "^2.23.1",
"@tiptap/extension-superscript": "^2.23.1",
"@tiptap/extension-table": "^2.23.1",
@@ -163,6 +164,7 @@
"swr": "^2.3.3",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
+ "tiptap-extension-resize-image": "^1.3.0",
"tmp-promise": "^3.0.3",
"ua-parser-js": "^2.0.4",
"uuid": "^11.0.5",
@@ -5414,6 +5416,20 @@
"@tiptap/core": "^2.7.0"
}
},
+ "node_modules/@tiptap/extension-placeholder": {
+ "version": "2.26.1",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.26.1.tgz",
+ "integrity": "sha512-MBlqbkd+63btY7Qu+SqrXvWjPwooGZDsLTtl7jp52BczBl61cq9yygglt9XpM11TFMBdySgdLHBrLtQ0B7fBlw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.7.0",
+ "@tiptap/pm": "^2.7.0"
+ }
+ },
"node_modules/@tiptap/extension-strike": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.26.1.tgz",
@@ -16430,6 +16446,17 @@
"@popperjs/core": "^2.9.0"
}
},
+ "node_modules/tiptap-extension-resize-image": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tiptap-extension-resize-image/-/tiptap-extension-resize-image-1.3.0.tgz",
+ "integrity": "sha512-5QfDhDBGEEyuoZqhek85KmrIkc9CNSAe5I0a2tx/wmAJ9ytiQ/w0OtrFRIpJDMnj3fNxUu+n5OLKnBFwXRZV9Q==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@tiptap/core": "^2.0.0 || ^3.0.0",
+ "@tiptap/extension-image": "^2.0.0 || ^3.0.0",
+ "@tiptap/pm": "^2.0.0 || ^3.0.0"
+ }
+ },
"node_modules/tmp": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
diff --git a/package.json b/package.json
index 7a7955e5..aa51b6ea 100644
--- a/package.json
+++ b/package.json
@@ -91,6 +91,7 @@
"@tiptap/extension-link": "^2.23.1",
"@tiptap/extension-list-item": "^2.23.1",
"@tiptap/extension-ordered-list": "^2.23.1",
+ "@tiptap/extension-placeholder": "^2.23.1",
"@tiptap/extension-subscript": "^2.23.1",
"@tiptap/extension-superscript": "^2.23.1",
"@tiptap/extension-table": "^2.23.1",
@@ -165,6 +166,7 @@
"swr": "^2.3.3",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
+ "tiptap-extension-resize-image": "^1.3.0",
"tmp-promise": "^3.0.3",
"ua-parser-js": "^2.0.4",
"uuid": "^11.0.5",
@@ -193,7 +195,7 @@
"tsx": "^4.19.2",
"typescript": "^5.7.2"
},
- "overrides": {
+ "overrides": {
"rimraf": "3.0.2"
}
}
diff --git a/public/wsdl/IF_ECC_EVCP_PCR.wsdl b/public/wsdl/IF_ECC_EVCP_PCR.wsdl
index d5b10bfc..fa229dac 100644
--- a/public/wsdl/IF_ECC_EVCP_PCR.wsdl
+++ b/public/wsdl/IF_ECC_EVCP_PCR.wsdl
@@ -28,13 +28,13 @@
<!-- SEQ:1, Table:ZMM_PCR, Field:PCR_REQ, M/O:M, Type:CHAR, Size:10, Description:PCR 요청번호 -->
<xs:element name="PCR_REQ" type="xs:string"/>
<!-- SEQ:2, Table:ZMM_PCR, Field:PCR_REQ_SEQ, M/O:M, Type:NUMC, Size:5, Description:PCR 요청순번 -->
- <xs:element name="PCR_REQ_SEQ" type="xs:integer"/>
+ <xs:element name="PCR_REQ_SEQ" type="xs:string"/>
<!-- SEQ:3, Table:ZMM_PCR, Field:PCR_REQ_DATE, M/O:M, Type:DATS, Size:8, Description:PCR 요청일자 -->
<xs:element name="PCR_REQ_DATE" type="xs:string"/>
<!-- SEQ:4, Table:ZMM_PCR, Field:EBELN, M/O:M, Type:CHAR, Size:10, Description:구매오더 -->
<xs:element name="EBELN" type="xs:string"/>
<!-- SEQ:5, Table:ZMM_PCR, Field:EBELP, M/O:M, Type:NUMC, Size:5, Description:구매오더 품번 -->
- <xs:element name="EBELP" type="xs:integer"/>
+ <xs:element name="EBELP" type="xs:string"/>
<!-- SEQ:6, Table:ZMM_PCR, Field:PCR_TYPE, M/O:M, Type:CHAR, Size:2, Description:물량/Spec 변경 Type : Q, W, S, QW -->
<xs:element name="PCR_TYPE" type="xs:string"/>
<!-- SEQ:7, Table:ZMM_PCR, Field:PSPID, M/O:, Type:CHAR, Size:24, Description:프로젝트 -->
@@ -42,7 +42,7 @@
<!-- SEQ:8, Table:ZMM_PCR, Field:BANFN, M/O:M, Type:CHAR, Size:10, Description:구매요청 -->
<xs:element name="BANFN" type="xs:string"/>
<!-- SEQ:9, Table:ZMM_PCR, Field:BNFPO, M/O:M, Type:NUMC, Size:5, Description:구매요청 품번 -->
- <xs:element name="BNFPO" type="xs:integer"/>
+ <xs:element name="BNFPO" type="xs:string"/>
<!-- SEQ:10, Table:ZMM_PCR, Field:MATNR, M/O:, Type:CHAR, Size:18, Description:자재번호 -->
<xs:element name="MATNR" type="xs:string" minOccurs="0"/>
<!-- SEQ:11, Table:ZMM_PCR, Field:MAKTX, M/O:, Type:CHAR, Size:40, Description:자재명 -->
@@ -52,25 +52,25 @@
<!-- SEQ:13, Table:ZMM_PCR, Field:ZSPEC_NUM, M/O:, Type:CHAR, Size:25, Description:POS -->
<xs:element name="ZSPEC_NUM" type="xs:string" minOccurs="0"/>
<!-- SEQ:14, Table:ZMM_PCR, Field:QTY_B, M/O:, Type:QUAN, Size:13,3, Description:변경 전 수량 -->
- <xs:element name="QTY_B" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="QTY_B" type="xs:string" minOccurs="0"/>
<!-- SEQ:15, Table:ZMM_PCR, Field:QTY_A, M/O:, Type:QUAN, Size:13,3, Description:변경 후 수량 -->
- <xs:element name="QTY_A" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="QTY_A" type="xs:string" minOccurs="0"/>
<!-- SEQ:16, Table:ZMM_PCR, Field:MEINS, M/O:, Type:UNIT, Size:3, Description:단위 -->
<xs:element name="MEINS" type="xs:string" minOccurs="0"/>
<!-- SEQ:17, Table:ZMM_PCR, Field:T_WEIGHT_B, M/O:, Type:QUAN, Size:13,3, Description:변경 전 Total 중량 -->
- <xs:element name="T_WEIGHT_B" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="T_WEIGHT_B" type="xs:string" minOccurs="0"/>
<!-- SEQ:18, Table:ZMM_PCR, Field:T_WEIGHT_A, M/O:, Type:QUAN, Size:13,3, Description:변경 후 Total 중량 -->
- <xs:element name="T_WEIGHT_A" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="T_WEIGHT_A" type="xs:string" minOccurs="0"/>
<!-- SEQ:19, Table:ZMM_PCR, Field:MEINS_W, M/O:, Type:UNIT, Size:3, Description:중량 단위 -->
<xs:element name="MEINS_W" type="xs:string" minOccurs="0"/>
<!-- SEQ:20, Table:ZMM_PCR, Field:S_WEIGHT_B, M/O:, Type:QUAN, Size:13,3, Description:변경 전 사급 중량 -->
- <xs:element name="S_WEIGHT_B" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="S_WEIGHT_B" type="xs:string" minOccurs="0"/>
<!-- SEQ:21, Table:ZMM_PCR, Field:S_WEIGHT_A, M/O:, Type:QUAN, Size:13,3, Description:변경 후 사급 중량 -->
- <xs:element name="S_WEIGHT_A" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="S_WEIGHT_A" type="xs:string" minOccurs="0"/>
<!-- SEQ:22, Table:ZMM_PCR, Field:C_WEIGHT_B, M/O:, Type:QUAN, Size:13,3, Description:변경 전 도급 중량 -->
- <xs:element name="C_WEIGHT_B" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="C_WEIGHT_B" type="xs:string" minOccurs="0"/>
<!-- SEQ:23, Table:ZMM_PCR, Field:C_WEIGHT_A, M/O:, Type:QUAN, Size:13,3, Description:변경 후 도급 중량 -->
- <xs:element name="C_WEIGHT_A" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="C_WEIGHT_A" type="xs:string" minOccurs="0"/>
<!-- SEQ:24, Table:ZMM_PCR, Field:ZACC_DT, M/O:, Type:DATS, Size:8, Description:구매담당자 PR 접수일 -->
<xs:element name="ZACC_DT" type="xs:string" minOccurs="0"/>
<!-- SEQ:25, Table:ZMM_PCR, Field:ERDAT, M/O:, Type:DATS, Size:8, Description:물량 변경일 -->
@@ -94,11 +94,11 @@
<!-- SEQ:34, Table:ZMM_PCR, Field:WAERS, M/O:M, Type:CUKY, Size:5, Description:PO 통화 -->
<xs:element name="WAERS" type="xs:string"/>
<!-- SEQ:35, Table:ZMM_PCR, Field:NETPR, M/O:M, Type:CURR, Size:13,2, Description:PO 단가 -->
- <xs:element name="NETPR" type="xs:decimal"/>
+ <xs:element name="NETPR" type="xs:string"/>
<!-- SEQ:36, Table:ZMM_PCR, Field:PEINH, M/O:, Type:DEC, Size:5, Description:Price Unit, 수량에 대한 PER 당 단가 -->
- <xs:element name="PEINH" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="PEINH" type="xs:string" minOccurs="0"/>
<!-- SEQ:37, Table:ZMM_PCR, Field:NETWR, M/O:M, Type:CURR, Size:13,2, Description:PO 금액 -->
- <xs:element name="NETWR" type="xs:decimal"/>
+ <xs:element name="NETWR" type="xs:string"/>
<!-- SEQ:38, Table:ZMM_PCR, Field:POSID, M/O:, Type:CHAR, Size:24, Description:WBS -->
<xs:element name="POSID" type="xs:string" minOccurs="0"/>
<!-- SEQ:39, Table:ZMM_PCR, Field:EKGRP, M/O:, Type:CHAR, Size:3, Description:구매그룹 -->
@@ -126,17 +126,24 @@
</xs:sequence>
</xs:complexType>
- <!-- 1.3) 수신측 응답 구조 -->
+ <!-- 1.4) 수신측 응답 구조 (최상위 타입 정의) -->
<xs:complexType name="IF_ECC_EVCP_PCRRes">
<xs:sequence>
+ <xs:element name="ZMM_RT" type="tns:ZMM_RT" minOccurs="1" maxOccurs="1"/>
+ </xs:sequence>
+ </xs:complexType>
+
+ <!-- 1.5) 수신측 응답 구조 복합타입 (ZMM_RT) -->
+ <xs:complexType name="ZMM_RT">
+ <xs:sequence>
<!-- SEQ:50, Table:ZMM_RT (수신측 응답), Field:PCR_REQ, M/O:M, Type:CHAR, Size:10, Description:PCR 요청번호 -->
<xs:element name="PCR_REQ" type="xs:string"/>
<!-- SEQ:51, Table:ZMM_RT (수신측 응답), Field:PCR_REQ_SEQ, M/O:M, Type:NUMC, Size:5, Description:PCR 요청순번 -->
- <xs:element name="PCR_REQ_SEQ" type="xs:integer"/>
+ <xs:element name="PCR_REQ_SEQ" type="xs:string"/>
<!-- SEQ:52, Table:ZMM_RT (수신측 응답), Field:EBELN, M/O:M, Type:CHAR, Size:10, Description:구매오더 -->
<xs:element name="EBELN" type="xs:string"/>
<!-- SEQ:53, Table:ZMM_RT (수신측 응답), Field:EBELP, M/O:M, Type:NUMC, Size:5, Description:구매오더 품번 -->
- <xs:element name="EBELP" type="xs:integer"/>
+ <xs:element name="EBELP" type="xs:string"/>
<!-- SEQ:54, Table:ZMM_RT (수신측 응답), Field:MSGTY, M/O:, Type:CHAR, Size:1, Description:Message Type -->
<xs:element name="MSGTY" type="xs:string" minOccurs="0"/>
<!-- SEQ:55, Table:ZMM_RT (수신측 응답), Field:MSGTXT, M/O:, Type:CHAR, Size:100, Description:Message Text -->
diff --git a/public/wsdl/IF_ECC_EVCP_PO_INFORMATION.wsdl b/public/wsdl/IF_ECC_EVCP_PO_INFORMATION.wsdl
index f5be8f32..38b5f43d 100644
--- a/public/wsdl/IF_ECC_EVCP_PO_INFORMATION.wsdl
+++ b/public/wsdl/IF_ECC_EVCP_PO_INFORMATION.wsdl
@@ -20,16 +20,15 @@
<xs:sequence>
<!-- Header 레코드 집합 -->
<xs:element name="ZMM_HD" type="tns:ZMM_HD" maxOccurs="unbounded" minOccurs="0"/>
- <!-- 지불방법 레코드 집합 -->
- <xs:element name="ZMM_HD_ZMM_PAY" type="tns:ZMM_HD_ZMM_PAY" maxOccurs="unbounded" minOccurs="0"/>
- <!-- PO Detail 레코드 집합 -->
- <xs:element name="ZMM_HD_ZMM_DT" type="tns:ZMM_HD_ZMM_DT" maxOccurs="unbounded" minOccurs="0"/>
- <!-- PO Detail 의 계정관련 레코드 집합 -->
- <xs:element name="ZMM_HD_ZMM_DT_ZMM_KN" type="tns:ZMM_HD_ZMM_DT_ZMM_KN" maxOccurs="unbounded" minOccurs="0"/>
- <!-- PO Note 1 -->
- <xs:element name="ZMM_HD_ZMM_NOTE" type="tns:ZMM_HD_ZMM_NOTE" maxOccurs="unbounded" minOccurs="0"/>
- <!-- PO Note 2 -->
- <xs:element name="ZMM_HD_ZMM_NOTE2" type="tns:ZMM_HD_ZMM_NOTE2" maxOccurs="unbounded" minOccurs="0"/>
+ <!-- 지불방법 레코드 집합 (ZMM_HD의 하위 테이블) -->
+ <xs:element name="ZMM_PAY" type="tns:ZMM_PAY" maxOccurs="unbounded" minOccurs="0"/>
+ <!-- PO Detail 레코드 집합 (ZMM_HD의 하위 테이블) -->
+ <xs:element name="ZMM_DT" type="tns:ZMM_DT" maxOccurs="unbounded" minOccurs="0"/>
+ <!-- KN 은 DT의 하위 테이블이므로 생략 -->
+ <!-- PO Note 1 (ZMM_HD의 하위 테이블) -->
+ <xs:element name="ZMM_NOTE" type="tns:ZMM_NOTE" maxOccurs="unbounded" minOccurs="0"/>
+ <!-- PO Note 2 (ZMM_HD의 하위 테이블) -->
+ <xs:element name="ZMM_NOTE2" type="tns:ZMM_NOTE2" maxOccurs="unbounded" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
@@ -59,7 +58,7 @@
<!-- SEQ:11, Table:ZMM_HD, Field:EKGRP, M/O:M, Type:CHAR, Size:3, Description:구매그룹코드 -->
<xs:element name="EKGRP" type="xs:string"/>
<!-- SEQ:12, Table:ZMM_HD, Field:WKURS, M/O:M, Type:DEC, Size:9,5, Description:환율 -->
- <xs:element name="WKURS" type="xs:decimal"/>
+ <xs:element name="WKURS" type="xs:string"/>
<!-- SEQ:13, Table:ZMM_HD, Field:BEDAT, M/O:M, Type:DATS, Size:8, Description:구매증빙일자 -->
<xs:element name="BEDAT" type="xs:string"/>
<!-- SEQ:14, Table:ZMM_HD, Field:INCO1, M/O:M, Type:CHAR, Size:3, Description:인도조건코드 -->
@@ -71,9 +70,9 @@
<!-- SEQ:17, Table:ZMM_HD, Field:ZIND_CD, M/O:M, Type:CHAR, Size:2, Description:증감코드 -->
<xs:element name="ZIND_CD" type="xs:string"/>
<!-- SEQ:18, Table:ZMM_HD, Field:ZDAMT_DD_SUBRT, M/O:M, Type:DEC, Size:6,2, Description:지체상금일일공제율 -->
- <xs:element name="ZDAMT_DD_SUBRT" type="xs:decimal"/>
+ <xs:element name="ZDAMT_DD_SUBRT" type="xs:string"/>
<!-- SEQ:19, Table:ZMM_HD, Field:ZMAX_SUBRT, M/O:M, Type:DEC, Size:6,2, Description:최대공제율 -->
- <xs:element name="ZMAX_SUBRT" type="xs:decimal"/>
+ <xs:element name="ZMAX_SUBRT" type="xs:string"/>
<!-- SEQ:20, Table:ZMM_HD, Field:ZCNRT_GRNT_CD, M/O:M, Type:CHAR, Size:1, Description:계약보증코드 -->
<xs:element name="ZCNRT_GRNT_CD" type="xs:string"/>
<!-- SEQ:21, Table:ZMM_HD, Field:ZDFCT_GRNT_CD, M/O:M, Type:CHAR, Size:1, Description:하자보증코드 -->
@@ -83,13 +82,13 @@
<!-- SEQ:23, Table:ZMM_HD, Field:ZPAMT_YN, M/O:M, Type:CHAR, Size:1, Description:선급금여부 -->
<xs:element name="ZPAMT_YN" type="xs:string"/>
<!-- SEQ:24, Table:ZMM_HD, Field:ZBGT_AMT, M/O:M, Type:CURR, Size:17,2, Description:예산금액, ZBTG_CURR -->
- <xs:element name="ZBGT_AMT" type="xs:decimal"/>
+ <xs:element name="ZBGT_AMT" type="xs:string"/>
<!-- SEQ:25, Table:ZMM_HD, Field:ZBGT_CURR, M/O:M, Type:CUKY, Size:3, Description:예산금액 통화키 -->
<xs:element name="ZBGT_CURR" type="xs:string"/>
<!-- SEQ:26, Table:ZMM_HD, Field:ZPO_AMT, M/O:M, Type:CURR, Size:17,2, Description:발주금액 -->
- <xs:element name="ZPO_AMT" type="xs:decimal"/>
+ <xs:element name="ZPO_AMT" type="xs:string"/>
<!-- SEQ:27, Table:ZMM_HD, Field:ZPO_AMT_KRW, M/O:M, Type:CURR, Size:17,2, Description:발주금액 KRW -->
- <xs:element name="ZPO_AMT_KRW" type="xs:decimal"/>
+ <xs:element name="ZPO_AMT_KRW" type="xs:string"/>
<!-- SEQ:28, Table:ZMM_HD, Field:ZPO_CURR, M/O:M, Type:CUKY, Size:5, Description:통화키 -->
<xs:element name="ZPO_CURR" type="xs:string"/>
<!-- SEQ:29, Table:ZMM_HD, Field:ZCHG_PO_DT, M/O:M, Type:DATS, Size:8, Description:변경발주일자 -->
@@ -117,15 +116,15 @@
<!-- SEQ:40, Table:ZMM_HD, Field:ZPO_TRANS_CANC, M/O:M, Type:DATS, Size:1, Description:전송여부지시자 -->
<xs:element name="ZPO_TRANS_CANC" type="xs:string"/>
<!-- SEQ:41, Table:ZMM_HD, Field:ZVST_TMS, M/O:M, Type:NUMC, Size:9, Description:방문횟수 -->
- <xs:element name="ZVST_TMS" type="xs:integer"/>
+ <xs:element name="ZVST_TMS" type="xs:string"/>
<!-- SEQ:42, Table:ZMM_HD, Field:ZSVC_WK_PRD, M/O:M, Type:NUMC, Size:9, Description:SE작업일수 -->
- <xs:element name="ZSVC_WK_PRD" type="xs:integer"/>
+ <xs:element name="ZSVC_WK_PRD" type="xs:string"/>
<!-- SEQ:43, Table:ZMM_HD, Field:ZDT_EXCS_AMT, M/O:M, Type:CURR, Size:17,2, Description:일초과금액1 -->
- <xs:element name="ZDT_EXCS_AMT" type="xs:decimal"/>
+ <xs:element name="ZDT_EXCS_AMT" type="xs:string"/>
<!-- SEQ:44, Table:ZMM_HD, Field:ZDT_EXCS_AMT2, M/O:M, Type:CURR, Size:17,2, Description:일초과금액2 -->
- <xs:element name="ZDT_EXCS_AMT2" type="xs:decimal"/>
+ <xs:element name="ZDT_EXCS_AMT2" type="xs:string"/>
<!-- SEQ:45, Table:ZMM_HD, Field:ZDT_EXCS_AMT3, M/O:M, Type:CURR, Size:17,2, Description:일초과금액3 -->
- <xs:element name="ZDT_EXCS_AMT3" type="xs:decimal"/>
+ <xs:element name="ZDT_EXCS_AMT3" type="xs:string"/>
<!-- SEQ:46, Table:ZMM_HD, Field:ZSVC_CNRT_CUR, M/O:M, Type:CUKY, Size:5, Description:SE계약통화 -->
<xs:element name="ZSVC_CNRT_CUR" type="xs:string"/>
<!-- SEQ:47, Table:ZMM_HD, Field:ZPAY_GB, M/O:M, Type:CHAR, Size:1, Description:기타비용처리구분 -->
@@ -139,7 +138,7 @@
<!-- SEQ:51, Table:ZMM_HD, Field:ZTITLE, M/O:M, Type:CHAR, Size:90, Description:발주제목 -->
<xs:element name="ZTITLE" type="xs:string"/>
<!-- SEQ:52, Table:ZMM_HD, Field:ZPO_VER, M/O:M, Type:NUMC, Size:2, Description:발주버전 -->
- <xs:element name="ZPO_VER" type="xs:integer"/>
+ <xs:element name="ZPO_VER" type="xs:string"/>
<!-- SEQ:53, Table:ZMM_HD, Field:ITEM_CATEGORY, M/O:M, Type:CHAR, Size:2, Description:선물환 Item Category -->
<xs:element name="ITEM_CATEGORY" type="xs:string"/>
<!-- SEQ:54, Table:ZMM_HD, Field:LTEXT, M/O:M, Type:CHAR, Size:60, Description:선물환 Item Category 명 -->
@@ -166,40 +165,39 @@
<xs:element name="ETC_9" type="xs:string"/>
<!-- SEQ:65, Table:ZMM_HD, Field:ETC_10, M/O:M, Type:CHAR, Size:100, Description:확장10 -->
<xs:element name="ETC_10" type="xs:string"/>
- <!-- SEQ:66, Table:ZMM_HD, Field:ZDLV_PRICE_T, M/O:, Type:CHAR, Size:1, Description:납품대금연동제대상여부
-(Y:대상, N:미대상, 공백:미해당) -->
+ <!-- SEQ:66, Table:ZMM_HD, Field:ZDLV_PRICE_T, M/O:, Type:CHAR, Size:1, Description:납품대금연동제대상여부 (Y:대상, N:미대상, 공백:미해당) -->
<xs:element name="ZDLV_PRICE_T" type="xs:string" minOccurs="0"/>
<!-- SEQ:67, Table:ZMM_HD, Field:ZWEBELN, M/O:, Type:CHAR, Size:10, Description:서면계약번호 -->
<xs:element name="ZWEBELN" type="xs:string" minOccurs="0"/>
<!-- SEQ:68, Table:ZMM_HD, Field:ZVER_NO, M/O:, Type:NUMC, Size:3, Description:서면계약차수 -->
- <xs:element name="ZVER_NO" type="xs:integer" minOccurs="0"/>
+ <xs:element name="ZVER_NO" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<!-- 1.3) ZMM_HD/ZMM_PAY 테이블 구조 (SEQ 69~73) -->
- <xs:complexType name="ZMM_HD_ZMM_PAY">
+ <xs:complexType name="ZMM_PAY">
<xs:sequence>
<!-- SEQ:69, Table:ZMM_HD/ZMM_PAY, Field:ZPAYSEQ, M/O:M, Type:CHAR, Size:2, Description:선급금차수 -->
<xs:element name="ZPAYSEQ" type="xs:string"/>
<!-- SEQ:70, Table:ZMM_HD/ZMM_PAY, Field:ZADVTYP, M/O:M, Type:CHAR, Size:1, Description:선급금타입 -->
<xs:element name="ZADVTYP" type="xs:string"/>
<!-- SEQ:71, Table:ZMM_HD/ZMM_PAY, Field:ZDWPRT, M/O:M, Type:NUMC, Size:3, Description:선급금비율 -->
- <xs:element name="ZDWPRT" type="xs:integer"/>
+ <xs:element name="ZDWPRT" type="xs:string"/>
<!-- SEQ:72, Table:ZMM_HD/ZMM_PAY, Field:ZDWPAMT, M/O:M, Type:CURR, Size:17,2, Description:선급금 -->
- <xs:element name="ZDWPAMT" type="xs:decimal"/>
+ <xs:element name="ZDWPAMT" type="xs:string"/>
<!-- SEQ:73, Table:ZMM_HD/ZMM_PAY, Field:ZDWPDAT, M/O:M, Type:DATS, Size:8, Description:지불계획일자 -->
<xs:element name="ZDWPDAT" type="xs:string"/>
</xs:sequence>
</xs:complexType>
<!-- 1.4) ZMM_HD/ZMM_DT 테이블 구조 (SEQ 74~165) -->
- <xs:complexType name="ZMM_HD_ZMM_DT">
+ <xs:complexType name="ZMM_DT">
<xs:sequence>
<!-- SEQ:74, Table:ZMM_HD/ZMM_DT, Field:EBELP, M/O:M, Type:NUMC, Size:5, Description:구매오더품목번호 -->
- <xs:element name="EBELP" type="xs:integer"/>
- <!-- SEQ:5, Table:ZMM_HD, Field:LOEKZ, M/O:M, Type:CHAR, Size:1, Description:구매문서삭제지시자 -->
+ <xs:element name="EBELP" type="xs:string"/>
+ <!-- SEQ:75, Table:ZMM_HD/ZMM_DT, Field:LOEKZ, M/O:M, Type:CHAR, Size:1, Description:구매문서삭제지시자 -->
<xs:element name="LOEKZ" type="xs:string"/>
- <!-- SEQ:6, Table:ZMM_HD, Field:AEDAT, M/O:M, Type:DATS, Size:8, Description:생성일자 -->
+ <!-- SEQ:76, Table:ZMM_HD/ZMM_DT, Field:AEDAT, M/O:M, Type:DATS, Size:8, Description:생성일자 -->
<xs:element name="AEDAT" type="xs:string"/>
<!-- SEQ:77, Table:ZMM_HD/ZMM_DT, Field:MAKTX, M/O:M, Type:CHAR, Size:120, Description:자재내역 -->
<xs:element name="MAKTX" type="xs:string"/>
@@ -214,17 +212,17 @@
<!-- SEQ:82, Table:ZMM_HD/ZMM_DT, Field:BEDNR, M/O:M, Type:CHAR, Size:10, Description:요청추적번호 -->
<xs:element name="BEDNR" type="xs:string"/>
<!-- SEQ:83, Table:ZMM_HD/ZMM_DT, Field:MENGE, M/O:M, Type:QUAN, Size:13,3, Description:구매오더수량 -->
- <xs:element name="MENGE" type="xs:decimal"/>
+ <xs:element name="MENGE" type="xs:string"/>
<!-- SEQ:84, Table:ZMM_HD/ZMM_DT, Field:NETPR, M/O:M, Type:CURR, Size:17,2, Description:구매단가 -->
- <xs:element name="NETPR" type="xs:decimal"/>
+ <xs:element name="NETPR" type="xs:string"/>
<!-- SEQ:85, Table:ZMM_HD/ZMM_DT, Field:PEINH, M/O:M, Type:DEC, Size:5, Description:가격단위값 -->
- <xs:element name="PEINH" type="xs:decimal"/>
+ <xs:element name="PEINH" type="xs:string"/>
<!-- SEQ:86, Table:ZMM_HD/ZMM_DT, Field:NETWR, M/O:M, Type:CURR, Size:17,2, Description:오더정가 -->
- <xs:element name="NETWR" type="xs:decimal"/>
+ <xs:element name="NETWR" type="xs:string"/>
<!-- SEQ:87, Table:ZMM_HD/ZMM_DT, Field:BRTWR, M/O:M, Type:CURR, Size:17,2, Description:오더총액 -->
- <xs:element name="BRTWR" type="xs:decimal"/>
+ <xs:element name="BRTWR" type="xs:string"/>
<!-- SEQ:88, Table:ZMM_HD/ZMM_DT, Field:WEBAZ, M/O:M, Type:DEC, Size:3, Description:입고소요일수 -->
- <xs:element name="WEBAZ" type="xs:decimal"/>
+ <xs:element name="WEBAZ" type="xs:string"/>
<!-- SEQ:89, Table:ZMM_HD/ZMM_DT, Field:MWSKZ, M/O:M, Type:CHAR, Size:2, Description:매출부가가치세코드 -->
<xs:element name="MWSKZ" type="xs:string"/>
<!-- SEQ:90, Table:ZMM_HD/ZMM_DT, Field:INSMK, M/O:M, Type:CHAR, Size:1, Description:재고유형 -->
@@ -246,19 +244,19 @@
<!-- SEQ:98, Table:ZMM_HD/ZMM_DT, Field:KNTTP, M/O:M, Type:CHAR, Size:1, Description:계정지정범주 -->
<xs:element name="KNTTP" type="xs:string"/>
<!-- SEQ:99, Table:ZMM_HD/ZMM_DT, Field:NTGEW, M/O:M, Type:QUAN, Size:13,3, Description:순중량 -->
- <xs:element name="NTGEW" type="xs:decimal"/>
+ <xs:element name="NTGEW" type="xs:string"/>
<!-- SEQ:100, Table:ZMM_HD/ZMM_DT, Field:GEWEI, M/O:M, Type:UNIT, Size:3, Description:중량단위 -->
<xs:element name="GEWEI" type="xs:string"/>
<!-- SEQ:101, Table:ZMM_HD/ZMM_DT, Field:BRGEW, M/O:M, Type:QUAN, Size:15,3, Description:총중량 -->
- <xs:element name="BRGEW" type="xs:decimal"/>
+ <xs:element name="BRGEW" type="xs:string"/>
<!-- SEQ:102, Table:ZMM_HD/ZMM_DT, Field:VOLUM, M/O:M, Type:QUAN, Size:15,3, Description:볼륨 -->
- <xs:element name="VOLUM" type="xs:decimal"/>
+ <xs:element name="VOLUM" type="xs:string"/>
<!-- SEQ:103, Table:ZMM_HD/ZMM_DT, Field:VOLEH, M/O:M, Type:UNIT, Size:3, Description:볼륨단위 -->
<xs:element name="VOLEH" type="xs:string"/>
<!-- SEQ:104, Table:ZMM_HD/ZMM_DT, Field:BANFN, M/O:M, Type:CHAR, Size:10, Description:구매요청번호 -->
<xs:element name="BANFN" type="xs:string"/>
<!-- SEQ:105, Table:ZMM_HD/ZMM_DT, Field:BNFPO, M/O:M, Type:NUMC, Size:5, Description:구매요청품목번호 -->
- <xs:element name="BNFPO" type="xs:integer"/>
+ <xs:element name="BNFPO" type="xs:string"/>
<!-- SEQ:106, Table:ZMM_HD/ZMM_DT, Field:UPTYP, M/O:M, Type:CHAR, Size:1, Description:하위품목범주 -->
<xs:element name="UPTYP" type="xs:string"/>
<!-- SEQ:107, Table:ZMM_HD/ZMM_DT, Field:UPVOR, M/O:M, Type:CHAR, Size:1, Description:하위품목존재여부 -->
@@ -271,18 +269,18 @@
<xs:element name="ZDST_CD" type="xs:string"/>
<!-- SEQ:111, Table:ZMM_HD/ZMM_DT, Field:ZRCV_DT, M/O:M, Type:DATS, Size:8, Description:구매접수일자 -->
<xs:element name="ZRCV_DT" type="xs:string"/>
- <!-- SEQ:50, Table:ZMM_HD, Field:ZCON_NO, M/O:M, Type:CHAR, Size:10, Description:구매통합번호 -->
+ <!-- SEQ:112, Table:ZMM_HD/ZMM_DT, Field:ZCON_NO, M/O:M, Type:CHAR, Size:10, Description:구매통합번호 -->
<xs:element name="ZCON_NO" type="xs:string"/>
<!-- SEQ:113, Table:ZMM_HD/ZMM_DT, Field:ZCON_IND, M/O:M, Type:CHAR, Size:1, Description:시리즈구분 -->
<xs:element name="ZCON_IND" type="xs:string"/>
<!-- SEQ:114, Table:ZMM_HD/ZMM_DT, Field:ZCHAR_CD, M/O:M, Type:CHAR, Size:1, Description:물성코드,풍력 일련번호 처리여부 -->
<xs:element name="ZCHAR_CD" type="xs:string"/>
<!-- SEQ:115, Table:ZMM_HD/ZMM_DT, Field:ZMAT_AREA, M/O:M, Type:QUAN, Size:13,3, Description:자재면적 -->
- <xs:element name="ZMAT_AREA" type="xs:decimal"/>
+ <xs:element name="ZMAT_AREA" type="xs:string"/>
<!-- SEQ:116, Table:ZMM_HD/ZMM_DT, Field:ZSZ, M/O:M, Type:CHAR, Size:50, Description:품목사이즈 -->
<xs:element name="ZSZ" type="xs:string"/>
<!-- SEQ:117, Table:ZMM_HD/ZMM_DT, Field:ZAF_ECAL_AMT, M/O:M, Type:CURR, Size:17,2, Description:사후정산금액(참고: NETWR), ZPO_CURR -->
- <xs:element name="ZAF_ECAL_AMT" type="xs:decimal"/>
+ <xs:element name="ZAF_ECAL_AMT" type="xs:string"/>
<!-- SEQ:118, Table:ZMM_HD/ZMM_DT, Field:ZPLN_ST_DT, M/O:M, Type:DATS, Size:8, Description:예정시작일자 -->
<xs:element name="ZPLN_ST_DT" type="xs:string"/>
<!-- SEQ:119, Table:ZMM_HD/ZMM_DT, Field:ZPLN_ED_DT, M/O:M, Type:DATS, Size:8, Description:예정종료일자 -->
@@ -290,61 +288,61 @@
<!-- SEQ:49, Table:ZMM_HD, Field:PSPID, M/O:M, Type:CHAR, Size:24, Description:프로젝트 번호 -->
<xs:element name="PSPID" type="xs:string"/>
<!-- SEQ:121, Table:ZMM_HD/ZMM_DT, Field:ZUSD_BGT, M/O:M, Type:CURR, Size:17,2, Description:미화예산 -->
- <xs:element name="ZUSD_BGT" type="xs:decimal"/>
+ <xs:element name="ZUSD_BGT" type="xs:string"/>
<!-- SEQ:122, Table:ZMM_HD/ZMM_DT, Field:ZKRW_BGT, M/O:M, Type:CURR, Size:17,2, Description:원화예산 -->
- <xs:element name="ZKRW_BGT" type="xs:decimal"/>
+ <xs:element name="ZKRW_BGT" type="xs:string"/>
<!-- SEQ:123, Table:ZMM_HD/ZMM_DT, Field:ZDLV_CNTLR, M/O:M, Type:CHAR, Size:3, Description:조달담당자코드 -->
<xs:element name="ZDLV_CNTLR" type="xs:string"/>
<!-- SEQ:124, Table:ZMM_HD/ZMM_DT, Field:ANFNR, M/O:M, Type:CHAR, Size:10, Description:RFQ번호 -->
<xs:element name="ANFNR" type="xs:string"/>
<!-- SEQ:125, Table:ZMM_HD/ZMM_DT, Field:ANFPS, M/O:M, Type:NUMC, Size:5, Description:RFQ품목번호 -->
- <xs:element name="ANFPS" type="xs:integer"/>
+ <xs:element name="ANFPS" type="xs:string"/>
<!-- SEQ:126, Table:ZMM_HD/ZMM_DT, Field:KONNR, M/O:M, Type:CHAR, Size:10, Description:계약번호 -->
<xs:element name="KONNR" type="xs:string"/>
<!-- SEQ:127, Table:ZMM_HD/ZMM_DT, Field:KTPNR, M/O:M, Type:NUMC, Size:5, Description:계약항목번호 -->
- <xs:element name="KTPNR" type="xs:integer"/>
+ <xs:element name="KTPNR" type="xs:string"/>
<!-- SEQ:128, Table:ZMM_HD/ZMM_DT, Field:ZCR_NO, M/O:M, Type:CHAR, Size:40, Description:CR번호 -->
<xs:element name="ZCR_NO" type="xs:string"/>
<!-- SEQ:129, Table:ZMM_HD/ZMM_DT, Field:ZCR_AMT, M/O:M, Type:CURR, Size:17,2, Description:EXTRA CREDIT 금액 -->
- <xs:element name="ZCR_AMT" type="xs:decimal"/>
+ <xs:element name="ZCR_AMT" type="xs:string"/>
<!-- SEQ:130, Table:ZMM_HD/ZMM_DT, Field:ZRT_CUR, M/O:M, Type:CUKY, Size:3, Description:실적통화 -->
<xs:element name="ZRT_CUR" type="xs:string"/>
<!-- SEQ:131, Table:ZMM_HD/ZMM_DT, Field:ZRT_AMT, M/O:M, Type:CURR, Size:17,2, Description:실적금액, ZRT_CURR -->
- <xs:element name="ZRT_AMT" type="xs:decimal"/>
+ <xs:element name="ZRT_AMT" type="xs:string"/>
<!-- SEQ:132, Table:ZMM_HD/ZMM_DT, Field:ZPO_UNIT, M/O:M, Type:UNIT, Size:3, Description:구매오더수량단위 -->
<xs:element name="ZPO_UNIT" type="xs:string"/>
<!-- SEQ:133, Table:ZMM_HD/ZMM_DT, Field:ZREF_NETPR, M/O:M, Type:CURR, Size:17,2, Description:참조단가, ZPO_CURR -->
- <xs:element name="ZREF_NETPR" type="xs:decimal"/>
+ <xs:element name="ZREF_NETPR" type="xs:string"/>
<!-- SEQ:134, Table:ZMM_HD/ZMM_DT, Field:ZNETPR, M/O:M, Type:CURR, Size:17,2, Description:발주단가, ZPO_CURR -->
- <xs:element name="ZNETPR" type="xs:decimal"/>
+ <xs:element name="ZNETPR" type="xs:string"/>
<!-- SEQ:135, Table:ZMM_HD/ZMM_DT, Field:BPRME, M/O:M, Type:UNIT, Size:3, Description:구매단가단위 -->
<xs:element name="BPRME" type="xs:string"/>
<!-- SEQ:136, Table:ZMM_HD/ZMM_DT, Field:ZDISPLN, M/O:M, Type:CHAR, Size:1, Description:설계기능 -->
<xs:element name="ZDISPLN" type="xs:string"/>
<!-- SEQ:137, Table:ZMM_HD/ZMM_DT, Field:ZORCT_CNRT_KRW, M/O:M, Type:CURR, Size:17,2, Description:외주비계약KRW -->
- <xs:element name="ZORCT_CNRT_KRW" type="xs:decimal"/>
+ <xs:element name="ZORCT_CNRT_KRW" type="xs:string"/>
<!-- SEQ:138, Table:ZMM_HD/ZMM_DT, Field:ZORCT_CNRT_USD, M/O:M, Type:CURR, Size:17,2, Description:외주비계약USD -->
- <xs:element name="ZORCT_CNRT_USD" type="xs:decimal"/>
+ <xs:element name="ZORCT_CNRT_USD" type="xs:string"/>
<!-- SEQ:139, Table:ZMM_HD/ZMM_DT, Field:ZETC_CNRT_KRW, M/O:M, Type:CURR, Size:17,2, Description:기타계약KRW -->
- <xs:element name="ZETC_CNRT_KRW" type="xs:decimal"/>
+ <xs:element name="ZETC_CNRT_KRW" type="xs:string"/>
<!-- SEQ:140, Table:ZMM_HD/ZMM_DT, Field:ZETC_CNRT_USD, M/O:M, Type:CURR, Size:17,2, Description:기타계약USD -->
- <xs:element name="ZETC_CNRT_USD" type="xs:decimal"/>
+ <xs:element name="ZETC_CNRT_USD" type="xs:string"/>
<!-- SEQ:141, Table:ZMM_HD/ZMM_DT, Field:ZEXTRA_AMT, M/O:M, Type:CURR, Size:17,2, Description:EXTRA금액, ZPO_CURR -->
- <xs:element name="ZEXTRA_AMT" type="xs:decimal"/>
+ <xs:element name="ZEXTRA_AMT" type="xs:string"/>
<!-- SEQ:142, Table:ZMM_HD/ZMM_DT, Field:ZCRDT_AMT, M/O:M, Type:CURR, Size:17,2, Description:CREDIT금액, ZPO_CURR -->
- <xs:element name="ZCRDT_AMT" type="xs:decimal"/>
+ <xs:element name="ZCRDT_AMT" type="xs:string"/>
<!-- SEQ:143, Table:ZMM_HD/ZMM_DT, Field:ZART, M/O:M, Type:CHAR, Size:2, Description:검사코드 -->
<xs:element name="ZART" type="xs:string"/>
<!-- SEQ:144, Table:ZMM_HD/ZMM_DT, Field:ART, M/O:M, Type:CHAR, Size:8, Description:검사유형(QMAT) -->
<xs:element name="ART" type="xs:string"/>
<!-- SEQ:145, Table:ZMM_HD/ZMM_DT, Field:ZPDT_BSE_UPR, M/O:M, Type:CURR, Size:17,2, Description:BASE금액, ZPO_CURR -->
- <xs:element name="ZPDT_BSE_UPR" type="xs:decimal"/>
+ <xs:element name="ZPDT_BSE_UPR" type="xs:string"/>
<!-- SEQ:146, Table:ZMM_HD/ZMM_DT, Field:ZPDT_EXTRA_UPR, M/O:M, Type:CURR, Size:17,2, Description:EXTRA금액, ZPO_CURR -->
- <xs:element name="ZPDT_EXTRA_UPR" type="xs:decimal"/>
+ <xs:element name="ZPDT_EXTRA_UPR" type="xs:string"/>
<!-- SEQ:147, Table:ZMM_HD/ZMM_DT, Field:ZPDT_EXDS_AMT, M/O:M, Type:CURR, Size:17,2, Description:할인/할증금액, ZPO_CURR -->
- <xs:element name="ZPDT_EXDS_AMT" type="xs:decimal"/>
+ <xs:element name="ZPDT_EXDS_AMT" type="xs:string"/>
<!-- SEQ:148, Table:ZMM_HD/ZMM_DT, Field:ZTRNS_UPR, M/O:M, Type:CURR, Size:17,2, Description:운송단가, ZPO_CURR -->
- <xs:element name="ZTRNS_UPR" type="xs:decimal"/>
+ <xs:element name="ZTRNS_UPR" type="xs:string"/>
<!-- SEQ:149, Table:ZMM_HD/ZMM_DT, Field:ZFST_DST_CD, M/O:M, Type:CHAR, Size:4, Description:발주초기착지코드 -->
<xs:element name="ZFST_DST_CD" type="xs:string"/>
<!-- SEQ:150, Table:ZMM_HD/ZMM_DT, Field:ZCHG_CHK, M/O:M, Type:CHAR, Size:1, Description:물량수정승인여부 -->
@@ -361,32 +359,36 @@
<xs:element name="ZWH_CNTLR" type="xs:string"/>
<!-- SEQ:156, Table:ZMM_HD/ZMM_DT, Field:LFDAT, M/O:M, Type:DATS, Size:8, Description:PR Delivery Date -->
<xs:element name="LFDAT" type="xs:string"/>
- <!-- SEQ:57, Table:ZMM_HD, Field:ETC_2, M/O:M, Type:CHAR, Size:100, Description:확장2 -->
+ <!-- SEQ:157, Table:ZMM_HD/ZMM_DT, Field:ETC_2, M/O:M, Type:CHAR, Size:100, Description:확장2 -->
<xs:element name="ETC_2" type="xs:string"/>
- <!-- SEQ:58, Table:ZMM_HD, Field:ETC_3, M/O:M, Type:CHAR, Size:100, Description:확장3 -->
+ <!-- SEQ:158, Table:ZMM_HD/ZMM_DT, Field:ETC_3, M/O:M, Type:CHAR, Size:100, Description:확장3 -->
<xs:element name="ETC_3" type="xs:string"/>
- <!-- SEQ:59, Table:ZMM_HD, Field:ETC_4, M/O:M, Type:CHAR, Size:100, Description:확장4 -->
+ <!-- SEQ:159, Table:ZMM_HD/ZMM_DT, Field:ETC_4, M/O:M, Type:CHAR, Size:100, Description:확장4 -->
<xs:element name="ETC_4" type="xs:string"/>
- <!-- SEQ:60, Table:ZMM_HD, Field:ETC_5, M/O:M, Type:CHAR, Size:100, Description:확장5 -->
+ <!-- SEQ:160, Table:ZMM_HD/ZMM_DT, Field:ETC_5, M/O:M, Type:CHAR, Size:100, Description:확장5 -->
<xs:element name="ETC_5" type="xs:string"/>
- <!-- SEQ:61, Table:ZMM_HD, Field:ETC_6, M/O:M, Type:CHAR, Size:100, Description:확장6 -->
+ <!-- SEQ:161, Table:ZMM_HD/ZMM_DT, Field:ETC_6, M/O:M, Type:CHAR, Size:100, Description:확장6 -->
<xs:element name="ETC_6" type="xs:string"/>
- <!-- SEQ:62, Table:ZMM_HD, Field:ETC_7, M/O:M, Type:CHAR, Size:100, Description:확장7 -->
+ <!-- SEQ:162, Table:ZMM_HD/ZMM_DT, Field:ETC_7, M/O:M, Type:CHAR, Size:100, Description:확장7 -->
<xs:element name="ETC_7" type="xs:string"/>
- <!-- SEQ:63, Table:ZMM_HD, Field:ETC_8, M/O:M, Type:CHAR, Size:100, Description:확장8 -->
+ <!-- SEQ:163, Table:ZMM_HD/ZMM_DT, Field:ETC_8, M/O:M, Type:CHAR, Size:100, Description:확장8 -->
<xs:element name="ETC_8" type="xs:string"/>
- <!-- SEQ:64, Table:ZMM_HD, Field:ETC_9, M/O:M, Type:CHAR, Size:100, Description:확장9 -->
+ <!-- SEQ:164, Table:ZMM_HD/ZMM_DT, Field:ETC_9, M/O:M, Type:CHAR, Size:100, Description:확장9 -->
<xs:element name="ETC_9" type="xs:string"/>
- <!-- SEQ:65, Table:ZMM_HD, Field:ETC_10, M/O:M, Type:CHAR, Size:100, Description:확장10 -->
+ <!-- SEQ:165, Table:ZMM_HD/ZMM_DT, Field:ETC_10, M/O:M, Type:CHAR, Size:100, Description:확장10 -->
<xs:element name="ETC_10" type="xs:string"/>
+
+ <!-- PO Detail(ZMM_DT) 의 계정관련 레코드 집합 (ZMM_DT의 하위 테이블) -->
+ <xs:element name="ZMM_KN" type="tns:ZMM_KN" maxOccurs="unbounded" minOccurs="0"/>
+
</xs:sequence>
</xs:complexType>
<!-- 1.5) ZMM_HD/ZMM_DT/ZMM_KN 테이블 구조 (SEQ 166~186) -->
- <xs:complexType name="ZMM_HD_ZMM_DT_ZMM_KN">
+ <xs:complexType name="ZMM_KN">
<xs:sequence>
<!-- SEQ:166, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:ZEKKN, M/O:M, Type:NUMC, Size:2, Description:계정지정순번 -->
- <xs:element name="ZEKKN" type="xs:integer"/>
+ <xs:element name="ZEKKN" type="xs:string"/>
<!-- SEQ:167, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:SAKTO, M/O:M, Type:CHAR, Size:10, Description:G/L계정번호 -->
<xs:element name="SAKTO" type="xs:string"/>
<!-- SEQ:168, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:GSBER, M/O:M, Type:CHAR, Size:4, Description:사업영역코드 -->
@@ -396,7 +398,7 @@
<!-- SEQ:170, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:VBELN, M/O:M, Type:CHAR, Size:10, Description:판매오더번호 -->
<xs:element name="VBELN" type="xs:string"/>
<!-- SEQ:171, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:VBELP, M/O:M, Type:NUMC, Size:6, Description:판매오더품목번호 -->
- <xs:element name="VBELP" type="xs:integer"/>
+ <xs:element name="VBELP" type="xs:string"/>
<!-- SEQ:172, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:ANLN1, M/O:M, Type:CHAR, Size:12, Description:주요자산번호 -->
<xs:element name="ANLN1" type="xs:string"/>
<!-- SEQ:173, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:ANLN2, M/O:M, Type:CHAR, Size:4, Description:자산하위번호 -->
@@ -414,9 +416,9 @@
<!-- SEQ:179, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:NPLNR, M/O:M, Type:CHAR, Size:12, Description:네트워크오더번호 -->
<xs:element name="NPLNR" type="xs:string"/>
<!-- SEQ:180, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:AUFPL, M/O:M, Type:NUMC, Size:10, Description:오더라우팅번호 -->
- <xs:element name="AUFPL" type="xs:integer"/>
+ <xs:element name="AUFPL" type="xs:string"/>
<!-- SEQ:181, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:APLZL, M/O:M, Type:NUMC, Size:8, Description:오더내부카운터 -->
- <xs:element name="APLZL" type="xs:integer"/>
+ <xs:element name="APLZL" type="xs:string"/>
<!-- SEQ:182, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:FIPOS, M/O:M, Type:CHAR, Size:14, Description:약정항목 -->
<xs:element name="FIPOS" type="xs:string"/>
<!-- SEQ:183, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:FISTL, M/O:M, Type:CHAR, Size:16, Description:자금관리센터 -->
@@ -431,41 +433,46 @@
</xs:complexType>
<!-- 1.6) ZMM_HD/ZMM_NOTE 테이블 구조 (SEQ 187~188) -->
- <xs:complexType name="ZMM_HD_ZMM_NOTE">
+ <xs:complexType name="ZMM_NOTE">
<xs:sequence>
<!-- SEQ:187, Table:ZMM_HD/ZMM_NOTE, Field:ZNOTE_SER, M/O:M, Type:NUMC, Size:4, Description:발주 Note 순번 -->
- <xs:element name="ZNOTE_SER" type="xs:integer"/>
+ <xs:element name="ZNOTE_SER" type="xs:string"/>
<!-- SEQ:188, Table:ZMM_HD/ZMM_NOTE, Field:ZNOTE_TXT, M/O:M, Type:CHAR, Size:4000, Description:발주 Note Text -->
<xs:element name="ZNOTE_TXT" type="xs:string"/>
</xs:sequence>
</xs:complexType>
<!-- 1.7) ZMM_HD/ZMM_NOTE2 테이블 구조 (SEQ 189~190) -->
- <xs:complexType name="ZMM_HD_ZMM_NOTE2">
+ <xs:complexType name="ZMM_NOTE2">
<xs:sequence>
<!-- SEQ:189, Table:ZMM_HD/ZMM_NOTE2, Field:ZDLV_PRICE_SER, M/O:, Type:NUMC, Size:4, Description:연동제 Note 순번 -->
- <xs:element name="ZDLV_PRICE_SER" type="xs:integer" minOccurs="0"/>
+ <xs:element name="ZDLV_PRICE_SER" type="xs:string" minOccurs="0"/>
<!-- SEQ:190, Table:ZMM_HD/ZMM_NOTE2, Field:ZDLV_PRICE_NOTE, M/O:, Type:CHAR, Size:4000, Description:연동제 Note Text -->
<xs:element name="ZDLV_PRICE_NOTE" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
- <!-- 1.8) 수신 시스템 응답 구조 (SEQ 191~193) -->
- <xs:complexType name="IF_ECC_EVCP_PO_INFORMATIONRes">
+ <!-- 1.8) ZMM_RT 테이블 구조 (SEQ 191~193) - 응답 복합타입 -->
+ <xs:complexType name="ZMM_RT">
<xs:sequence>
-
- <!-- SEQ:191, Table:ZMM_HD, Field:EBELN, M/O:M, Type:CHAR, Size:10, Description:구매오더번호 -->
+ <!-- SEQ:191, Table:ZMM_RT, Field:EBELN, M/O:M, Type:CHAR, Size:10, Description:구매오더번호 -->
<xs:element name="EBELN" type="xs:string"/>
-
<!-- SEQ:192, Table:ZMM_RT, Field:RT_CODE, M/O:M, Type:CHAR, Size:1, Description:IF상태 -->
<xs:element name="RT_CODE" type="xs:string"/>
-
<!-- SEQ:193, Table:ZMM_RT, Field:RT_TEXT, M/O:M, Type:CHAR, Size:100, Description:IF메세지 -->
<xs:element name="RT_TEXT" type="xs:string"/>
</xs:sequence>
</xs:complexType>
- <!-- 1.9) Element 래퍼 -->
+ <!-- 1.9) 수신 시스템 응답 구조 (SEQ 191~193) -->
+ <xs:complexType name="IF_ECC_EVCP_PO_INFORMATIONRes">
+ <xs:sequence>
+ <!-- 정의한 복합 응답 타입 사용 -->
+ <xs:element name="ZMM_RT" type="tns:ZMM_RT" minOccurs="1" maxOccurs="1"/>
+ </xs:sequence>
+ </xs:complexType>
+
+ <!-- 1.10) Element 래퍼 -->
<xs:element name="IF_ECC_EVCP_PO_INFORMATIONReq" type="tns:IF_ECC_EVCP_PO_INFORMATIONReq"/>
<xs:element name="IF_ECC_EVCP_PO_INFORMATIONRes" type="tns:IF_ECC_EVCP_PO_INFORMATIONRes"/>
</xsd:schema>
diff --git a/public/wsdl/IF_ECC_EVCP_PR_INFORMATION.wsdl b/public/wsdl/IF_ECC_EVCP_PR_INFORMATION.wsdl
index 705c10bb..d5ef7bf7 100644
--- a/public/wsdl/IF_ECC_EVCP_PR_INFORMATION.wsdl
+++ b/public/wsdl/IF_ECC_EVCP_PR_INFORMATION.wsdl
@@ -75,29 +75,29 @@
<!-- SEQ:20, Table:T_BID_ITEM, Field:POSID, M/O:, Type:VARCHAR, Size:24, Description:WBS No -->
<xs:element name="POSID" type="xs:string" minOccurs="0"/>
<!-- SEQ:21, Table:T_BID_ITEM, Field:MENGE, M/O:, Type:NUMERIC, Size:15,3, Description:Purchase Requisition Quantity -->
- <xs:element name="MENGE" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="MENGE" type="xs:string" minOccurs="0"/>
<!-- SEQ:22, Table:T_BID_ITEM, Field:MEINS, M/O:, Type:VARCHAR, Size:3, Description:Purchase Requisition Unit of Measure -->
<xs:element name="MEINS" type="xs:string" minOccurs="0"/>
<!-- SEQ:23, Table:T_BID_ITEM, Field:BPRME, M/O:, Type:VARCHAR, Size:3, Description:Order Price Unit (Purchasing) -->
<xs:element name="BPRME" type="xs:string" minOccurs="0"/>
<!-- SEQ:24, Table:T_BID_ITEM, Field:BRGEW, M/O:, Type:NUMERIC, Size:15,3, Description:Gross Weight -->
- <xs:element name="BRGEW" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="BRGEW" type="xs:string" minOccurs="0"/>
<!-- SEQ:25, Table:T_BID_ITEM, Field:GEWEI, M/O:, Type:VARCHAR, Size:3, Description:Weight Unit -->
<xs:element name="GEWEI" type="xs:string" minOccurs="0"/>
<!-- SEQ:26, Table:T_BID_ITEM, Field:LFDAT, M/O:, Type:VARCHAR, Size:8, Description:Delivery Date -->
<xs:element name="LFDAT" type="xs:string" minOccurs="0"/>
<!-- SEQ:27, Table:T_BID_ITEM, Field:PREIS, M/O:, Type:CURR, Size:15,2, Description:Price in Purchase Requisition -->
- <xs:element name="PREIS" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="PREIS" type="xs:string" minOccurs="0"/>
<!-- SEQ:28, Table:T_BID_ITEM, Field:WAERS1, M/O:, Type:VARCHAR, Size:5, Description:PR Currency Key -->
<xs:element name="WAERS1" type="xs:string" minOccurs="0"/>
<!-- SEQ:29, Table:T_BID_ITEM, Field:PEINH, M/O:, Type:NUMERIC, Size:5,0, Description:Price Unit -->
- <xs:element name="PEINH" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="PEINH" type="xs:string" minOccurs="0"/>
<!-- SEQ:30, Table:T_BID_ITEM, Field:KNTTP, M/O:, Type:VARCHAR, Size:1, Description:Account Assignment Category -->
<xs:element name="KNTTP" type="xs:string" minOccurs="0"/>
<!-- SEQ:31, Table:T_BID_ITEM, Field:AUFNR, M/O:, Type:VARCHAR, Size:12, Description:Order Number -->
<xs:element name="AUFNR" type="xs:string" minOccurs="0"/>
<!-- SEQ:32, Table:T_BID_ITEM, Field:ZRSLT_AMT, M/O:, Type:CURR, Size:17,2, Description:Reference Price -->
- <xs:element name="ZRSLT_AMT" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="ZRSLT_AMT" type="xs:string" minOccurs="0"/>
<!-- SEQ:33, Table:T_BID_ITEM, Field:WAERS2, M/O:, Type:VARCHAR, Size:5, Description:Reference Price Currency Key -->
<xs:element name="WAERS2" type="xs:string" minOccurs="0"/>
<!-- SEQ:34, Table:T_BID_ITEM, Field:ZCON_NO_PO, M/O:, Type:VARCHAR, Size:15, Description:PR Consolidation Number -->
diff --git a/public/wsdl/IF_ECC_EVCP_REJECT_FOR_REVISED_PR.wsdl b/public/wsdl/IF_ECC_EVCP_REJECT_FOR_REVISED_PR.wsdl
index 07d60fc8..e3f84f41 100644
--- a/public/wsdl/IF_ECC_EVCP_REJECT_FOR_REVISED_PR.wsdl
+++ b/public/wsdl/IF_ECC_EVCP_REJECT_FOR_REVISED_PR.wsdl
@@ -17,30 +17,22 @@
<!-- 1.1) 최상위 Request 복합타입 -->
<xs:complexType name="IF_ECC_EVCP_REJECT_FOR_REVISED_PRReq">
<xs:sequence>
- <!-- 메타데이터 레코드 집합 -->
- <xs:element name="METADATA" type="tns:METADATA" maxOccurs="unbounded" minOccurs="0"/>
+ <!-- SEQ:1, Table:없음(최상위), Field:IV_ERDAT, M/O:M, Type:DATS, Size:8, Description:Reject Date -->
+ <xs:element name="IV_ERDAT" type="xs:string"/>
+ <!-- SEQ:2, Table:없음(최상위), Field:IV_ERZET, M/O:M, Type:TIMS, Size:6, Description:Reject Time -->
+ <xs:element name="IV_ERZET" type="xs:string"/>
<!-- T_CHANGE_PR 레코드 집합 -->
<xs:element name="T_CHANGE_PR" type="tns:T_CHANGE_PR" maxOccurs="unbounded" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
- <!-- 1.2) METADATA 테이블 구조 -->
- <xs:complexType name="METADATA">
- <xs:sequence>
- <!-- SEQ:1, Table:메타데이터(저장하지 않음), Field:IV_ERDAT, M/O:M, Type:DATS, Size:8, Description:Reject Date -->
- <xs:element name="IV_ERDAT" type="xs:string"/>
- <!-- SEQ:2, Table:메타데이터(저장하지 않음), Field:IV_ERZET, M/O:M, Type:TIMS, Size:6, Description:Reject Time -->
- <xs:element name="IV_ERZET" type="xs:string"/>
- </xs:sequence>
- </xs:complexType>
-
<!-- 1.3) T_CHANGE_PR 테이블 구조 -->
<xs:complexType name="T_CHANGE_PR">
<xs:sequence>
<!-- SEQ:3, Table:T_CHANGE_PR, Field:BANFN, M/O:M, Type:CHAR, Size:10, Description:Purchase Requisition Number -->
<xs:element name="BANFN" type="xs:string"/>
<!-- SEQ:4, Table:T_CHANGE_PR, Field:BANPO, M/O:M, Type:NUMC, Size:5, Description:Item Number of Purchase Requisition -->
- <xs:element name="BANPO" type="xs:integer"/>
+ <xs:element name="BANPO" type="xs:string"/>
<!-- SEQ:5, Table:T_CHANGE_PR, Field:ZCHG_NO, M/O:M, Type:CHAR, Size:10, Description:Change Number -->
<xs:element name="ZCHG_NO" type="xs:string"/>
<!-- SEQ:6, Table:T_CHANGE_PR, Field:ZACC_IND, M/O:, Type:CHAR, Size:1, Description:P/R Accept Indicator -->
@@ -48,11 +40,11 @@
<!-- SEQ:7, Table:T_CHANGE_PR, Field:PCR_REQ, M/O:, Type:CHAR, Size:10, Description:PCR Request No. -->
<xs:element name="PCR_REQ" type="xs:string" minOccurs="0"/>
<!-- SEQ:8, Table:T_CHANGE_PR, Field:PCR_REQ_SEQ, M/O:, Type:NUMC, Size:5, Description:PCR Request Sequence No. -->
- <xs:element name="PCR_REQ_SEQ" type="xs:integer" minOccurs="0"/>
+ <xs:element name="PCR_REQ_SEQ" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
- <!-- 1.4) 수신측 응답 구조 -->
+ <!-- 1.4) 수신측 응답 구조 (최상위, 하위 테이블 구조 없음) -->
<xs:complexType name="IF_ECC_EVCP_REJECT_FOR_REVISED_PRRes">
<xs:sequence>
<!-- SEQ:9, Table:수신측 응답, Field:EV_TYPE, M/O:, Type:CHAR, Size:1, Description:Message Type -->
diff --git a/public/wsdl/IF_EVCP_ECC_PCR_CONFIRM.wsdl b/public/wsdl/IF_EVCP_ECC_PCR_CONFIRM.wsdl
index e69de29b..1dc9a9b9 100644
--- a/public/wsdl/IF_EVCP_ECC_PCR_CONFIRM.wsdl
+++ b/public/wsdl/IF_EVCP_ECC_PCR_CONFIRM.wsdl
@@ -0,0 +1 @@
+<!-- 송신 WSDL 이므로, SAP XI에서 WSDL 생성해서 받을 예정 --> \ No newline at end of file
diff --git a/public/wsdl/품질/IF_ECC_EVCP_PR_INFORMATION.wsdl b/public/wsdl/품질/IF_ECC_EVCP_PR_INFORMATION.wsdl
index 09828dda..fb23f7ce 100644
--- a/public/wsdl/품질/IF_ECC_EVCP_PR_INFORMATION.wsdl
+++ b/public/wsdl/품질/IF_ECC_EVCP_PR_INFORMATION.wsdl
@@ -55,18 +55,18 @@
<xs:element name="MATKL" type="xs:string" minOccurs="0"/>
<xs:element name="PSPID" type="xs:string" minOccurs="0"/>
<xs:element name="POSID" type="xs:string" minOccurs="0"/>
- <xs:element name="MENGE" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="MENGE" type="xs:string" minOccurs="0"/>
<xs:element name="MEINS" type="xs:string" minOccurs="0"/>
<xs:element name="BPRME" type="xs:string" minOccurs="0"/>
- <xs:element name="BRGEW" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="BRGEW" type="xs:string" minOccurs="0"/>
<xs:element name="GEWEI" type="xs:string" minOccurs="0"/>
<xs:element name="LFDAT" type="xs:string" minOccurs="0"/>
- <xs:element name="PREIS" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="PREIS" type="xs:string" minOccurs="0"/>
<xs:element name="WAERS1" type="xs:string" minOccurs="0"/>
- <xs:element name="PEINH" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="PEINH" type="xs:string" minOccurs="0"/>
<xs:element name="KNTTP" type="xs:string" minOccurs="0"/>
<xs:element name="AUFNR" type="xs:string" minOccurs="0"/>
- <xs:element name="ZRSLT_AMT" type="xs:decimal" minOccurs="0"/>
+ <xs:element name="ZRSLT_AMT" type="xs:string" minOccurs="0"/>
<xs:element name="WAERS2" type="xs:string" minOccurs="0"/>
<xs:element name="ZCON_NO_PO" type="xs:string" minOccurs="0"/>
<xs:element name="EBELP" type="xs:string" minOccurs="0"/>