summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.development4
-rw-r--r--.env.production4
-rw-r--r--app/[lng]/admin/approval-test/page.tsx10
-rw-r--r--app/[lng]/admin/approval-test/page.tsx.bak32
-rw-r--r--app/[lng]/admin/mdg/page.tsx.bak277
-rw-r--r--app/api/auth/[...nextauth]/route.ts1
-rw-r--r--components/common/user/user-selector.tsx447
-rw-r--r--components/knox/approval/ApprovalCancel.tsx34
-rw-r--r--components/knox/approval/ApprovalDetail.tsx58
-rw-r--r--components/knox/approval/ApprovalList.tsx38
-rw-r--r--components/knox/approval/ApprovalManager.tsx92
-rw-r--r--components/knox/approval/ApprovalSubmit.tsx1063
-rw-r--r--components/knox/approval/index.ts3
-rw-r--r--components/knox/approval/mocks/approval-mock.ts230
-rw-r--r--components/qna/tiptap-editor.tsx16
-rw-r--r--components/rich-text-editor/RichTextEditor.tsx998
-rw-r--r--components/spread-js/dataBinding.tsx4
-rw-r--r--components/spread-js/testSheet.tsx4
-rw-r--r--db/schema/index.ts9
-rw-r--r--db/schema/knox/approvals.ts16
-rw-r--r--db/schema/users.ts1
-rw-r--r--lib/knox-api/approval/approval.ts74
-rw-r--r--lib/knox-api/approval/service.ts140
-rw-r--r--lib/knox-api/common.ts1
-rw-r--r--lib/knox-sync/employee-sync-service.ts3
-rw-r--r--lib/knox-sync/master-sync-service.ts2
-rw-r--r--lib/users/service.ts95
27 files changed, 2708 insertions, 948 deletions
diff --git a/.env.development b/.env.development
index 6b74e5de..2ea1c4b0 100644
--- a/.env.development
+++ b/.env.development
@@ -117,7 +117,7 @@ MDG_SOAP_USERNAME=P2038_01 # 개발/품질/운영 공통
# MDG_SOAP_PASSWORD=STG4857602 # 개발
MDG_SOAP_PASSWORD=SEW2765890 # 품질
# MDG_SOAP_PASSWORD=POI9807861 # 운영
-SOAP_LOG_MAX_RECORDS=500
+SOAP_LOG_MAX_RECORDS=5000
# === SOAP 인터페이스 설정 ===
# === KNOX API 사용을 위한 설정 ===
@@ -128,7 +128,7 @@ KNOX_SYSTEM_ID="KCD60REST00046"
KNOX_API_BEARER="5a84ab62-d523-3602-ad3d-e3421893ae0c" # 운영
# 동기화 설정
KNOX_API_FORCE_LIMIT=false # 주간대량호출 강제 제한 여부
-KNOX_API_HOURLY_LIMIT=90 # 시간당 API 호출횟수 제한
+KNOX_API_HOURLY_LIMIT=400 # 시간당 API 호출횟수 제한
# KNOX_API_CALL_DELAY_MS # API 배치 처리간 딜레이 수동 설정이며, 자동 산출값보다 높아야 적용
KNOX_MASTER_SYNC_CRON="0 2 * * *" # cron 스케줄, 새벽 2시에 적용되며, 직급-조직도-임직원 순으로 적용.
KNOX_MASTER_SYNC_FIRST_RUN="false" # 앱 시작시 동기화 시작 여부
diff --git a/.env.production b/.env.production
index 0efe9996..17dbaa42 100644
--- a/.env.production
+++ b/.env.production
@@ -118,7 +118,7 @@ MDG_SOAP_USERNAME=P2038_01 # 개발/품질/운영 공통
# MDG_SOAP_PASSWORD=STG4857602 # 개발
MDG_SOAP_PASSWORD=SEW2765890 # 품질
# MDG_SOAP_PASSWORD=POI9807861 # 운영
-SOAP_LOG_MAX_RECORDS=500
+SOAP_LOG_MAX_RECORDS=5000
# === SOAP 인터페이스 설정 ===
# === KNOX API 사용을 위한 설정 ===
@@ -129,7 +129,7 @@ KNOX_SYSTEM_ID="KCD60REST00046"
KNOX_API_BEARER="5a84ab62-d523-3602-ad3d-e3421893ae0c" # 운영
# 동기화 설정
KNOX_API_FORCE_LIMIT=false # 주간대량호출 강제 제한 여부
-KNOX_API_HOURLY_LIMIT=90 # 시간당 API 호출횟수 제한
+KNOX_API_HOURLY_LIMIT=400 # 시간당 API 호출횟수 제한
# KNOX_API_CALL_DELAY_MS # API 배치 처리간 딜레이 수동 설정이며, 자동 산출값보다 높아야 적용
KNOX_MASTER_SYNC_CRON="0 2 * * *" # cron 스케줄, 새벽 2시에 적용되며, 직급-조직도-임직원 순으로 적용.
KNOX_MASTER_SYNC_FIRST_RUN="false" # 앱 시작시 동기화 시작 여부
diff --git a/app/[lng]/admin/approval-test/page.tsx b/app/[lng]/admin/approval-test/page.tsx
index f044d87d..ab5654f3 100644
--- a/app/[lng]/admin/approval-test/page.tsx
+++ b/app/[lng]/admin/approval-test/page.tsx
@@ -2,8 +2,8 @@ import { Metadata } from 'next';
import ApprovalManager from '@/components/knox/approval/ApprovalManager';
export const metadata: Metadata = {
- title: 'Knox 결재 시스템 테스트 | Admin',
- description: 'Knox API를 사용한 결재 시스템 기능 테스트용',
+ title: 'Knox 결재 시스템 | Admin',
+ description: 'Knox API를 사용한 결재 시스템',
};
export default function ApprovalTestPage() {
@@ -12,18 +12,14 @@ export default function ApprovalTestPage() {
<div className="space-y-6">
{/* 페이지 헤더 */}
<div className="space-y-2">
- <h1 className="text-3xl font-bold tracking-tight">Knox 결재 시스템 테스트</h1>
+ <h1 className="text-3xl font-bold tracking-tight">Knox 결재 시스템</h1>
<p className="text-muted-foreground">
Knox API를 사용한 결재 시스템 컴포넌트입니다.
- <br />
- 테스트 모드가 기본적으로 활성화되어 있으며, 테스트 모드에서는 실제 API 대신 모킹 데이터를 사용
</p>
</div>
{/* 결재 관리자 컴포넌트 */}
<ApprovalManager
- useFakeData={true}
- systemId="EVCP_TEST_SYSTEM"
defaultTab="submit"
/>
</div>
diff --git a/app/[lng]/admin/approval-test/page.tsx.bak b/app/[lng]/admin/approval-test/page.tsx.bak
deleted file mode 100644
index de0e2b5a..00000000
--- a/app/[lng]/admin/approval-test/page.tsx.bak
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Metadata } from 'next';
-import ApprovalManager from '@/components/knox/approval/ApprovalManager';
-
-export const metadata: Metadata = {
- title: 'Knox 결재 시스템 테스트 | Admin',
- description: 'Knox API를 사용한 결재 시스템의 모든 기능을 테스트할 수 있는 페이지입니다.',
-};
-
-export default function ApprovalTestPage() {
- return (
- <div className="container mx-auto py-8">
- <div className="space-y-6">
- {/* 페이지 헤더 */}
- <div className="space-y-2">
- <h1 className="text-3xl font-bold tracking-tight">Knox 결재 시스템 테스트</h1>
- <p className="text-muted-foreground">
- Knox API를 사용한 결재 시스템의 모든 기능을 테스트할 수 있습니다.
- <br />
- 테스트 모드가 기본적으로 활성화되어 있으며, 실제 API 대신 가짜 데이터를 사용합니다.
- </p>
- </div>
-
- {/* 결재 관리자 컴포넌트 */}
- <ApprovalManager
- useFakeData={true}
- systemId="EVCP_TEST_SYSTEM"
- defaultTab="submit"
- />
- </div>
- </div>
- );
-}
diff --git a/app/[lng]/admin/mdg/page.tsx.bak b/app/[lng]/admin/mdg/page.tsx.bak
deleted file mode 100644
index e2926deb..00000000
--- a/app/[lng]/admin/mdg/page.tsx.bak
+++ /dev/null
@@ -1,277 +0,0 @@
-'use client'
-
-import { useState, useEffect } from 'react'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Badge } from '@/components/ui/badge'
-import { toast } from 'sonner'
-import { Loader2, Send, RefreshCw } from 'lucide-react'
-
-// CSV 필드를 정의할 타입
-interface VendorFieldDef {
- table: string;
- name: string;
- mandatory: boolean;
- description: string;
-}
-
-// CSV 파싱 함수 (간단 파서)
-const parseCSV = (csv: string): VendorFieldDef[] => {
- const lines = csv.trim().split('\n');
- // 첫 번째 라인은 헤더이므로 제거
- return lines.slice(1).map((line) => {
- const parts = line.split(',');
- const table = parts[1]?.trim();
- const name = parts[2]?.trim();
- const mandatory = parts[3]?.trim() === 'M';
- const description = parts.slice(6).join(',').trim();
- return { table, name, mandatory, description } as VendorFieldDef;
- });
-};
-
-// 기존 샘플 기본값 (필요 시 확장)
-const sampleDefaults: Record<string, string> = {
- BP_HEADER: 'TEST001',
- ZZSRMCD: 'EVCP',
- TITLE: 'TEST',
- BU_SORT1: 'TEST VENDOR',
- NAME_ORG1: '테스트 벤더 회사',
- KTOKK: 'Z001',
- VEN_KFBUS: '제조업',
- VEN_KFIND: 'IT',
- MASTERFLAG: 'X',
- IBND_TYPE: 'U',
- ZZREQID: 'TESTUSER01',
- ADDRNO: '0001',
- AD_NATION: '1',
- COUNTRY: 'KR',
- LANGU_COM: 'K',
- POST_COD1: '06292',
- CITY1: '서울시',
- DISTRICT: '강남구',
- REGION: '11',
- MC_STREET: '테헤란로 123',
- T_COUNTRY: 'KR',
- T_NUMBER: '02-1234-5678',
- F_COUNTRY: 'KR',
- F_NUMBER: '02-1234-5679',
- U_ADDRESS: 'https://test.vendor.com',
- E_ADDRESS: 'contact@test.vendor.com',
- BP_TX_TYP: 'KR2',
- TAXNUM: '123-45-67890',
- AD_CONSNO: '1',
-};
-
-// XML escape helper
-const escapeXml = (unsafe: string) => unsafe.replace(/[<>&'"']/g, (c) => {
- switch (c) {
- case '<': return '&lt;';
- case '>': return '&gt;';
- case '&': return '&amp;';
- case '"': return '&quot;';
- case "'": return '&apos;';
- default: return c;
- }
-});
-
-export default function MDGTestPage() {
- const [formData, setFormData] = useState<Record<string, string>>({});
- const [fieldDefs, setFieldDefs] = useState<VendorFieldDef[]>([]);
- const [resultXml, setResultXml] = useState<string>('');
- const [isLoading, setIsLoading] = useState(false);
-
- // CSV 로딩 및 초기 데이터 셋업
- useEffect(() => {
- const load = async () => {
- const res = await fetch('/wsdl/P2MD3007_AO.csv');
- const csvText = await res.text();
- const defs = parseCSV(csvText);
- setFieldDefs(defs);
-
- const init: Record<string, string> = {};
- defs.forEach((d) => {
- init[d.name] = sampleDefaults[d.name] ?? '';
- });
- setFormData(init);
- };
-
- load();
- }, []);
-
- // XML 생성 유틸리티 (폼 데이터 -> SOAP Envelope)
- const buildEnvelopeXml = (currentForm: Record<string, string>, defs: VendorFieldDef[]) => {
- if (defs.length === 0) return '';
- const bodyContent = defs.map((f) => {
- const val = currentForm[f.name] ?? '';
- return `<${f.name}>${escapeXml(val)}</${f.name}>`;
- }).join('\n ');
-
- const supplierXml = `<SUPPLIER_MASTER>\n ${bodyContent}\n </SUPPLIER_MASTER>`;
-
- return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<soap:Envelope xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\" xmlns:p1=\"http://shi.samsung.co.kr/P2_MD/MDZ\">\n <soap:Header/>\n <soap:Body>\n <p1:MT_P2MD3007_S>\n <P2MD3007_S>\n ${supplierXml}\n </P2MD3007_S>\n </p1:MT_P2MD3007_S>\n </soap:Body>\n</soap:Envelope>`;
- };
-
- // 폼 데이터 변경 시 실시간 XML 생성
- useEffect(() => {
- const xml = buildEnvelopeXml(formData, fieldDefs);
- setResultXml(xml);
- }, [formData, fieldDefs]);
-
- // 폼 데이터 업데이트
- const updateField = (field: string, value: string) => {
- setFormData(prev => ({ ...prev, [field]: value }));
- };
-
- // 기본값으로 리셋
- const resetForm = () => {
- const reset: Record<string, string> = {};
- fieldDefs.forEach((d) => {
- reset[d.name] = sampleDefaults[d.name] ?? '';
- });
- setFormData(reset);
- toast.success('폼이 기본값으로 리셋되었습니다.');
- };
-
- // 테스트 송신 실행 (실제 서버 호출)
- const handleTestSend = async () => {
- try {
- setIsLoading(true);
-
- // 필수 필드 검증
- const requiredFields = fieldDefs.filter(d => d.mandatory).map(d => d.name);
- const missingFields = requiredFields.filter(field => !formData[field]?.trim());
-
- if (missingFields.length > 0) {
- toast.error(`필수 필드가 누락되었습니다: ${missingFields.join(', ')}`);
- setIsLoading(false);
- return;
- }
-
- // 서버 API 호출해 송신
- const res = await fetch('/api/mdg/send-vendor-xml', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ envelope: resultXml }),
- });
-
- const json = await res.json();
-
- if (!res.ok || !json.success) {
- // 상세 오류 메시지 추출 (vendorCode 기반 또는 직접 오류 메시지)
- const detailMsg = json?.results?.[0]?.error ?? json?.message ?? json?.responseText ?? '송신 실패';
- toast.error(`송신 실패: ${detailMsg}`);
- setIsLoading(false);
- return;
- }
-
- toast.success('MDG 송신이 완료되었습니다.');
-
- } catch (error) {
- console.error('테스트 송신 실패:', error);
- toast.error('테스트 송신 중 오류가 발생했습니다.');
- } finally {
- setIsLoading(false);
- }
- };
-
- return (
- <div className="container mx-auto p-6 space-y-6">
- <div className="flex items-center justify-between">
- <div>
- <h1 className="text-3xl font-bold">MDG VENDOR 마스터 테스트</h1>
- <p className="text-muted-foreground mt-2">
- VENDOR 마스터 데이터를 MDG 시스템으로 테스트 송신합니다
- </p>
- </div>
- <div className="flex gap-2">
- <Button variant="outline" onClick={resetForm}>
- <RefreshCw className="w-4 h-4 mr-2" />
- 리셋
- </Button>
- <Button onClick={handleTestSend} disabled={isLoading}>
- {isLoading ? (
- <Loader2 className="w-4 h-4 mr-2 animate-spin" />
- ) : (
- <Send className="w-4 h-4 mr-2" />
- )}
- 테스트 송신
- </Button>
- </div>
- </div>
-
- {/* 동적 필드 렌더링 */}
- {fieldDefs.length === 0 ? (
- <p className="text-center text-muted-foreground">CSV 로딩 중...</p>
- ) : (
- <div className="space-y-6">
- {Object.entries(
- fieldDefs.reduce((acc: Record<string, VendorFieldDef[]>, cur) => {
- acc[cur.table] = acc[cur.table] ? [...acc[cur.table], cur] : [cur];
- return acc;
- }, {})
- ).map(([table, fields]) => (
- <Card key={table}>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- {table}
- {fields.some(f => f.mandatory) && (
- <Badge variant="destructive">필수 포함</Badge>
- )}
- </CardTitle>
- <CardDescription>{table} 테이블 입력</CardDescription>
- </CardHeader>
- <CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {fields.filter((f, idx, arr) => arr.findIndex(x => x.name === f.name) === idx).map((field) => (
- <div key={field.name}>
- <Label htmlFor={field.name} className="flex items-center gap-1">
- {field.name}
- {field.mandatory && (
- <Badge variant="destructive" className="ml-1">필수</Badge>
- )}
- </Label>
- <Input
- id={field.name}
- value={formData[field.name] ?? ''}
- onChange={(e) => updateField(field.name, e.target.value)}
- />
- {field.description && (
- <p className="text-xs text-muted-foreground mt-1">{field.description}</p>
- )}
- </div>
- ))}
- </CardContent>
- </Card>
- ))}
- </div>
- )}
-
- {/* 송신 결과 영역 */}
- <Card>
- <CardHeader>
- <CardTitle>송신 결과</CardTitle>
- <CardDescription>
- MDG 시스템으로의 송신 결과가 여기에 표시됩니다
- </CardDescription>
- </CardHeader>
- <CardContent>
- {resultXml ? (
- <pre className="p-4 bg-muted max-h-96 overflow-auto text-xs whitespace-pre-wrap">
- {resultXml}
- </pre>
- ) : (
- <div className="p-4 bg-muted rounded-lg">
- <p className="text-sm text-muted-foreground">
- 테스트 송신 버튼을 클릭하면 결과가 표시됩니다.
- </p>
- </div>
- )}
- </CardContent>
- </Card>
- </div>
- );
-}
-
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
index fe93906d..5896fb90 100644
--- a/app/api/auth/[...nextauth]/route.ts
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -235,6 +235,7 @@ export const authOptions: NextAuthOptions = {
authMethod: tempAuth.authMethod as AuthMethod,
dbSessionId: dbSession.id,
roles: userRoles, // ✅ roles 배열 추가
+ epId: user.epId, // Knox 계정인 경우, epId 추가 (Knox API 사용하는 경우 필요)
}
} catch (error) {
diff --git a/components/common/user/user-selector.tsx b/components/common/user/user-selector.tsx
new file mode 100644
index 00000000..4a43fa5e
--- /dev/null
+++ b/components/common/user/user-selector.tsx
@@ -0,0 +1,447 @@
+"use client"
+
+import * as React from "react"
+import { Search, X, Users, 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import { Skeleton } from "@/components/ui/skeleton"
+import { searchUsersForSelector } from "@/lib/users/service"
+
+// User 타입 정의
+export interface UserSelectItem {
+ id: number
+ name: string
+ email: string
+ epId?: string | null
+ deptCode?: string | null
+ deptName?: string | null
+ imageUrl?: string | null
+ domain?: string
+ companyName?: string | null
+}
+
+// Domain 필터 타입
+export type UserDomainFilter =
+ | { type: "exclude"; domains: string[] } // partners가 아닌 경우
+ | { type: "include"; domains: string[] } // 특정 domain인 경우
+ | null // 필터 없음
+
+// 페이지네이션 정보 타입
+interface PaginationInfo {
+ page: number
+ perPage: number
+ total: number
+ pageCount: number
+ hasNextPage: boolean
+ hasPrevPage: boolean
+}
+
+export interface UserSelectorProps {
+ /** 선택된 사용자들 */
+ selectedUsers?: UserSelectItem[]
+ /** 사용자 선택 변경 콜백 */
+ onUsersChange?: (users: UserSelectItem[]) => void
+ /** 단일 선택 모드 여부 */
+ singleSelect?: boolean
+ /** domain 필터 */
+ domainFilter?: UserDomainFilter
+ /** placeholder 텍스트 */
+ placeholder?: string
+ /** 입력 없이 focus 시 표시할 placeholder */
+ noValuePlaceHolder?: string
+ /** 비활성화 여부 */
+ disabled?: boolean
+ /** 최대 선택 가능 사용자 수 */
+ maxSelections?: number
+ /** 조직도 선택 다이얼로그 오픈 콜백 (추후 구현) */
+ onOpenOrgChart?: () => void
+ /** 컴포넌트 클래스명 */
+ className?: string
+ /** 사용자 선택 후 팝오버 닫기 여부 */
+ closeOnSelect?: boolean
+}
+
+export function UserSelector({
+ selectedUsers = [],
+ onUsersChange,
+ singleSelect = false,
+ domainFilter,
+ placeholder = "사용자를 검색하세요...",
+ noValuePlaceHolder = "사용자를 검색하거나 조직도에서 찾아보세요",
+ disabled = false,
+ maxSelections,
+ onOpenOrgChart,
+ className,
+ closeOnSelect = true // 기본값으로 선택 후 닫기
+}: UserSelectorProps) {
+ const [searchQuery, setSearchQuery] = React.useState("")
+ const [isSearching, setIsSearching] = React.useState(false)
+ const [searchResults, setSearchResults] = React.useState<UserSelectItem[]>([])
+ 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)
+
+ // 검색 실행 - useCallback으로 메모이제이션
+ const performSearch = React.useCallback(async (query: string, page: number = 1) => {
+ setIsSearching(true)
+ setSearchError(null)
+
+ try {
+ const result = await searchUsersForSelector(query, page, 10, domainFilter)
+
+ if (result.success) {
+ setSearchResults(result.data)
+ setPagination(result.pagination)
+ setCurrentPage(page)
+ } else {
+ setSearchResults([])
+ setSearchError("검색 중 오류가 발생했습니다.")
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ })
+ }
+ } catch (err) {
+ console.error("사용자 검색 실패:", err)
+ setSearchResults([])
+ setSearchError("검색 중 오류가 발생했습니다.")
+ setPagination({
+ page: 1,
+ perPage: 10,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ })
+ } finally {
+ setIsSearching(false)
+ }
+ }, [domainFilter])
+
+ // Debounced 검색어 변경 시 검색 실행
+ React.useEffect(() => {
+ setCurrentPage(1)
+ performSearch(debouncedSearchQuery, 1)
+ }, [debouncedSearchQuery, performSearch])
+
+ // 페이지 변경 처리 - useCallback으로 메모이제이션
+ const handlePageChange = React.useCallback((newPage: number) => {
+ if (newPage >= 1 && newPage <= pagination.pageCount) {
+ performSearch(debouncedSearchQuery, newPage)
+ }
+ }, [pagination.pageCount, performSearch, debouncedSearchQuery])
+
+ // 사용자 선택 처리 - useCallback으로 메모이제이션
+ const handleUserSelect = React.useCallback((user: UserSelectItem) => {
+ if (disabled) return
+
+ const isSelected = selectedUsers.some(u => u.id === user.id)
+ let newSelection: UserSelectItem[]
+
+ if (singleSelect) {
+ newSelection = isSelected ? [] : [user]
+ } else {
+ if (isSelected) {
+ newSelection = selectedUsers.filter(u => u.id !== user.id)
+ } else {
+ if (maxSelections && selectedUsers.length >= maxSelections) {
+ return // 최대 선택 수 도달
+ }
+ newSelection = [...selectedUsers, user]
+ }
+ }
+
+ onUsersChange?.(newSelection)
+
+ // 선택 후 팝오버 닫기 (closeOnSelect가 true이거나 단일 선택 모드일 때)
+ if ((closeOnSelect || singleSelect) && !isSelected) {
+ setIsPopoverOpen(false)
+ setSearchQuery("")
+ }
+ }, [disabled, selectedUsers, singleSelect, maxSelections, onUsersChange, closeOnSelect])
+
+ // 선택된 사용자 제거 - useCallback으로 메모이제이션
+ const handleRemoveUser = React.useCallback((userId: number) => {
+ if (disabled) return
+ const newSelection = selectedUsers.filter(u => u.id !== userId)
+ onUsersChange?.(newSelection)
+ }, [disabled, selectedUsers, onUsersChange])
+
+ // 사용자 이니셜 생성 - useMemo로 메모이제이션
+ const getUserInitials = React.useCallback((name: string) => {
+ const names = name.split(' ')
+ return names.length > 1
+ ? `${names[0][0]}${names[1][0]}`
+ : name.slice(0, 2)
+ }, [])
+
+ // Input 이벤트 핸들러들
+ const handleInputFocus = React.useCallback(() => {
+ setIsPopoverOpen(true)
+ }, [])
+
+ const handleInputChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+ setSearchQuery(e.target.value)
+ if (!isPopoverOpen) {
+ setIsPopoverOpen(true)
+ }
+ }, [isPopoverOpen])
+
+ const handleClosePopover = React.useCallback(() => {
+ setIsPopoverOpen(false)
+ }, [])
+
+ // 계산된 값들 - useMemo로 메모이제이션
+ const shouldShowResults = React.useMemo(() =>
+ searchQuery.trim() || isSearching, [searchQuery, isSearching])
+
+ const hasResults = React.useMemo(() =>
+ searchResults.length > 0, [searchResults.length])
+
+ // 검색 결과 렌더링 - 컴포넌트 분리로 가독성 향상
+ const renderSearchResults = React.useMemo(() => {
+ if (!shouldShowResults) {
+ return (
+ <div className="p-4 text-sm text-muted-foreground text-center">
+ {noValuePlaceHolder}
+ </div>
+ )
+ }
+
+ if (isSearching) {
+ return (
+ <div className="p-2 space-y-2">
+ {Array.from({ length: 3 }).map((_, i) => (
+ <div key={i} className="flex items-center space-x-2 p-2">
+ <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs">
+ ?
+ </div>
+ <div className="space-y-1 flex-1">
+ <Skeleton className="h-3 w-full" />
+ </div>
+ </div>
+ ))}
+ </div>
+ )
+ }
+
+ if (searchError) {
+ return (
+ <div className="p-4 text-sm text-destructive text-center">
+ {searchError}
+ </div>
+ )
+ }
+
+ if (!hasResults) {
+ return (
+ <div className="p-4 text-sm text-muted-foreground text-center">
+ 검색 결과가 없습니다.
+ </div>
+ )
+ }
+
+ return (
+ <>
+ <div className="p-1">
+ {searchResults.map((user) => {
+ const isSelected = selectedUsers.some(u => u.id === user.id)
+ const canSelect = !maxSelections || selectedUsers.length < maxSelections || isSelected
+
+ return (
+ <div
+ key={user.id}
+ onClick={() => handleUserSelect(user)}
+ className={cn(
+ "flex items-center space-x-3 p-2 rounded-md cursor-pointer hover:bg-accent",
+ !canSelect && !isSelected && "opacity-50 cursor-not-allowed"
+ )}
+ >
+ <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs">
+ {getUserInitials(user.name)}
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 text-sm">
+ <span className="font-medium">{user.name}</span>
+ <span className="text-muted-foreground">·</span>
+ <span className="text-muted-foreground">{user.email}</span>
+ {user.deptName && (
+ <>
+ <span className="text-muted-foreground">·</span>
+ <span className="text-muted-foreground">{user.deptName}</span>
+ </>
+ )}
+ {isSelected && <div className="h-2 w-2 bg-primary rounded-full ml-auto" />}
+ </div>
+ </div>
+ </div>
+ )
+ })}
+ </div>
+
+ {/* 페이지네이션 */}
+ {pagination.pageCount > 1 && (
+ <div className="flex items-center justify-between p-3 border-t bg-muted/30">
+ <div className="text-xs text-muted-foreground">
+ {pagination.total}명 중 {((pagination.page - 1) * pagination.perPage) + 1}-{Math.min(pagination.page * pagination.perPage, pagination.total)}명
+ </div>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage - 1)}
+ disabled={!pagination.hasPrevPage}
+ className="h-8 w-8 p-0"
+ >
+ <ChevronLeft className="h-4 w-4" />
+ </Button>
+ <span className="text-xs text-muted-foreground px-2">
+ {pagination.page} / {pagination.pageCount}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage + 1)}
+ disabled={!pagination.hasNextPage}
+ className="h-8 w-8 p-0"
+ >
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ )}
+ </>
+ )
+ }, [
+ shouldShowResults,
+ noValuePlaceHolder,
+ isSearching,
+ searchError,
+ hasResults,
+ searchResults,
+ selectedUsers,
+ maxSelections,
+ handleUserSelect,
+ getUserInitials,
+ pagination,
+ currentPage,
+ handlePageChange
+ ])
+
+ return (
+ <div className={cn("space-y-2", className)}>
+ {/* 검색 입력 영역 */}
+ <div className="flex gap-2">
+ <div className="flex-1 relative">
+ <div className="relative">
+ <Input
+ ref={inputRef}
+ placeholder={placeholder}
+ value={searchQuery}
+ onChange={handleInputChange}
+ onFocus={handleInputFocus}
+ disabled={disabled}
+ className="pr-10"
+ />
+ <Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+ </div>
+
+ {/* 검색 결과 팝오버 */}
+ {isPopoverOpen && (
+ <div className="absolute top-full left-0 right-0 z-50 mt-1 rounded-md border bg-popover text-popover-foreground shadow-md outline-none">
+ {/* 팝오버 헤더 */}
+ <div className="flex items-center justify-between p-3 border-b">
+ <span className="text-sm font-medium">사용자 선택</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleClosePopover}
+ className="h-6 w-6 p-0"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+
+ {/* 검색 결과 영역 */}
+ <div className="max-h-[400px] overflow-y-auto">
+ {renderSearchResults}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 조직도 찾기 버튼 */}
+ {onOpenOrgChart && (
+ <Button
+ variant="outline"
+ onClick={onOpenOrgChart}
+ disabled={disabled}
+ className="px-3"
+ >
+ <Users className="h-4 w-4 mr-2" />
+ 찾기
+ </Button>
+ )}
+ </div>
+
+ {/* 선택된 사용자들 표시 */}
+ {selectedUsers.length > 0 && (
+ <div className="flex flex-wrap gap-2">
+ {selectedUsers.map((user) => (
+ <Badge
+ key={user.id}
+ variant="secondary"
+ className="flex items-center gap-2 px-3 py-1"
+ >
+ <div className="h-5 w-5 rounded-full bg-muted flex items-center justify-center text-xs">
+ {getUserInitials(user.name)}
+ </div>
+ <span className="text-sm">{user.name}</span>
+ {user.deptName && (
+ <span className="text-xs text-muted-foreground">({user.deptName})</span>
+ )}
+ {!disabled && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-4 w-4 p-0 hover:bg-transparent"
+ onClick={() => handleRemoveUser(user.id)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ )}
+ </Badge>
+ ))}
+ </div>
+ )}
+
+ {/* 선택 제한 안내 */}
+ {maxSelections && selectedUsers.length >= maxSelections && (
+ <div className="text-xs text-muted-foreground">
+ 최대 {maxSelections}명까지 선택할 수 있습니다.
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/components/knox/approval/ApprovalCancel.tsx b/components/knox/approval/ApprovalCancel.tsx
index d077bfc6..b40b43df 100644
--- a/components/knox/approval/ApprovalCancel.tsx
+++ b/components/knox/approval/ApprovalCancel.tsx
@@ -15,19 +15,29 @@ import { Loader2, XCircle, AlertTriangle, CheckCircle } from 'lucide-react';
import { cancelApproval, getApprovalDetail } from '@/lib/knox-api/approval/approval';
import type { ApprovalDetailResponse } from '@/lib/knox-api/approval/approval';
-// Mock 데이터
-import { mockApprovalAPI, getStatusText } from './mocks/approval-mock';
+// 상태 코드 텍스트 매핑 (mock util 대체)
+const getStatusText = (status: string) => {
+ const map: Record<string, string> = {
+ '-3': '암호화실패',
+ '-2': '암호화중',
+ '-1': '예약상신',
+ '0': '보류',
+ '1': '진행중',
+ '2': '완결',
+ '3': '반려',
+ '4': '상신취소',
+ '5': '전결',
+ '6': '후완결',
+ };
+ return map[status] || '알 수 없음';
+};
interface ApprovalCancelProps {
- useFakeData?: boolean;
- systemId?: string;
initialApInfId?: string;
onCancelSuccess?: (apInfId: string) => void;
}
export default function ApprovalCancel({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
initialApInfId = '',
onCancelSuccess
}: ApprovalCancelProps) {
@@ -50,9 +60,7 @@ export default function ApprovalCancel({
setCancelResult(null);
try {
- const response = useFakeData
- ? await mockApprovalAPI.getApprovalDetail(apInfId)
- : await getApprovalDetail(apInfId, systemId);
+ const response = await getApprovalDetail(apInfId);
if (response.result === 'SUCCESS') {
setApprovalDetail(response.data);
@@ -75,9 +83,7 @@ export default function ApprovalCancel({
setIsCancelling(true);
try {
- const response = useFakeData
- ? await mockApprovalAPI.cancelApproval(approvalDetail.apInfId)
- : await cancelApproval(approvalDetail.apInfId, systemId);
+ const response = await cancelApproval(approvalDetail.apInfId);
if (response.result === 'SUCCESS') {
setCancelResult({ apInfId: response.data.apInfId });
@@ -154,14 +160,14 @@ export default function ApprovalCancel({
};
return (
- <Card className="w-full max-w-4xl">
+ <Card className="w-full max-w-5xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<XCircle className="w-5 h-5" />
결재 취소
</CardTitle>
<CardDescription>
- 결재 ID를 입력하여 상신을 취소합니다. {useFakeData && '(테스트 모드)'}
+ 상신한 결재를 취소합니다.
</CardDescription>
</CardHeader>
diff --git a/components/knox/approval/ApprovalDetail.tsx b/components/knox/approval/ApprovalDetail.tsx
index 6db43cbe..d6c883cb 100644
--- a/components/knox/approval/ApprovalDetail.tsx
+++ b/components/knox/approval/ApprovalDetail.tsx
@@ -14,12 +14,37 @@ import { Loader2, Search, FileText, Clock, User, AlertCircle } from 'lucide-reac
import { getApprovalDetail, getApprovalContent } from '@/lib/knox-api/approval/approval';
import type { ApprovalDetailResponse, ApprovalContentResponse, ApprovalLine } from '@/lib/knox-api/approval/approval';
-// Mock 데이터
-import { mockApprovalAPI, getStatusText, getRoleText, getApprovalStatusText } from './mocks/approval-mock';
+// 상태/역할 텍스트 매핑 (mock util 대체)
+const getStatusText = (status: string) => {
+ const map: Record<string, string> = {
+ '-3': '암호화실패',
+ '-2': '암호화중',
+ '-1': '예약상신',
+ '0': '보류',
+ '1': '진행중',
+ '2': '완결',
+ '3': '반려',
+ '4': '상신취소',
+ '5': '전결',
+ '6': '후완결',
+ };
+ return map[status] || status;
+};
+
+const getRoleText = (role: string) => {
+ const map: Record<string, string> = {
+ '0': '기안',
+ '1': '결재',
+ '2': '합의',
+ '3': '후결',
+ '4': '병렬합의',
+ '7': '병렬결재',
+ '9': '통보',
+ };
+ return map[role] || role;
+};
interface ApprovalDetailProps {
- useFakeData?: boolean;
- systemId?: string;
initialApInfId?: string;
}
@@ -38,8 +63,6 @@ interface ApprovalAttachment {
}
export default function ApprovalDetail({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
initialApInfId = ''
}: ApprovalDetailProps) {
const [apInfId, setApInfId] = useState(initialApInfId);
@@ -59,12 +82,8 @@ export default function ApprovalDetail({
try {
const [detailResponse, contentResponse] = await Promise.all([
- useFakeData
- ? mockApprovalAPI.getApprovalDetail(id)
- : getApprovalDetail(id, systemId),
- useFakeData
- ? mockApprovalAPI.getApprovalContent(id)
- : getApprovalContent(id, systemId)
+ getApprovalDetail(id),
+ getApprovalContent(id)
]);
if (detailResponse.result === 'SUCCESS' && contentResponse.result === 'SUCCESS') {
@@ -146,11 +165,11 @@ export default function ApprovalDetail({
// 2) fileId + 별도 엔드포인트 조합 (가이드에 명시되지 않았으므로 best-effort 처리)
if (attachment.fileId) {
- const url = `${process.env.KNOX_API_BASE_URL}/approval/api/v2.0/attachments/${attachment.fileId}`;
+ const url = `${process.env.NEXT_PUBLIC_KNOX_API_BASE_URL || ''}/approval/api/v2.0/attachments/${attachment.fileId}`;
const resp = await fetch(url, {
method: 'GET',
headers: {
- 'System-ID': systemId,
+ 'System-ID': process.env.NEXT_PUBLIC_KNOX_SYSTEM_ID || '',
},
});
if (!resp.ok) throw new Error('다운로드 실패');
@@ -177,17 +196,17 @@ export default function ApprovalDetail({
if (initialApInfId) {
fetchApprovalDetail(initialApInfId);
}
- }, [initialApInfId, useFakeData, systemId]);
+ }, [initialApInfId]);
return (
- <Card className="w-full max-w-6xl">
+ <Card className="w-full max-w-5xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
결재 상세 조회
</CardTitle>
<CardDescription>
- 결재 ID를 입력하여 상세 정보를 조회합니다. {useFakeData && '(테스트 모드)'}
+ 결재 ID를 입력하여 상세 정보를 조회합니다.
</CardDescription>
</CardHeader>
@@ -354,7 +373,7 @@ export default function ApprovalDetail({
<div className="mt-1">
<Badge variant={apln.aplnStatsCode === '1' ? 'default' :
apln.aplnStatsCode === '2' ? 'destructive' : 'outline'}>
- {getApprovalStatusText(apln.aplnStatsCode)}
+ {getStatusText(apln.aplnStatsCode)}
</Badge>
</div>
</div>
@@ -384,7 +403,8 @@ export default function ApprovalDetail({
<h3 className="text-lg font-semibold">첨부파일</h3>
<div className="space-y-2">
- {approvalData.detail.attachments.map((attachment: ApprovalAttachment, index: number) => (
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ {approvalData.detail.attachments.map((attachment: any, index: number) => (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<FileText className="w-4 h-4 text-gray-500" />
<div className="flex-1">
diff --git a/components/knox/approval/ApprovalList.tsx b/components/knox/approval/ApprovalList.tsx
index 7f80e74a..3a766901 100644
--- a/components/knox/approval/ApprovalList.tsx
+++ b/components/knox/approval/ApprovalList.tsx
@@ -12,12 +12,24 @@ import { Loader2, List, Eye, RefreshCw, AlertCircle } from 'lucide-react';
import { getSubmissionList, getApprovalHistory } from '@/lib/knox-api/approval/approval';
import type { SubmissionListResponse, ApprovalHistoryResponse } from '@/lib/knox-api/approval/approval';
-// Mock 데이터
-import { mockApprovalAPI, getStatusText } from './mocks/approval-mock';
+// 상태 텍스트 매핑 (mock util 대체)
+const getStatusText = (status: string) => {
+ const map: Record<string, string> = {
+ '-3': '암호화실패',
+ '-2': '암호화중',
+ '-1': '예약상신',
+ '0': '보류',
+ '1': '진행중',
+ '2': '완결',
+ '3': '반려',
+ '4': '상신취소',
+ '5': '전결',
+ '6': '후완결',
+ };
+ return map[status] || '알 수 없음';
+};
interface ApprovalListProps {
- useFakeData?: boolean;
- systemId?: string;
type?: 'submission' | 'history';
onItemClick?: (apInfId: string) => void;
}
@@ -35,8 +47,6 @@ type ListItem = {
};
export default function ApprovalList({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
type = 'submission',
onItemClick
}: ApprovalListProps) {
@@ -52,17 +62,13 @@ export default function ApprovalList({
let response: SubmissionListResponse | ApprovalHistoryResponse;
if (type === 'submission') {
- response = useFakeData
- ? await mockApprovalAPI.getSubmissionList()
- : await getSubmissionList(systemId);
+ response = await getSubmissionList();
} else {
- response = useFakeData
- ? await mockApprovalAPI.getApprovalHistory()
- : await getApprovalHistory(systemId);
+ response = await getApprovalHistory();
}
if (response.result === 'SUCCESS') {
- setListData(response.data);
+ setListData(response.data as unknown as ListItem[]);
} else {
setError('목록을 가져오는데 실패했습니다.');
toast.error('목록을 가져오는데 실패했습니다.');
@@ -142,10 +148,10 @@ export default function ApprovalList({
// 컴포넌트 마운트 시 데이터 로드
useEffect(() => {
fetchData();
- }, [type, useFakeData, systemId]);
+ }, [type]);
return (
- <Card className="w-full max-w-6xl">
+ <Card className="w-full max-w-5xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<List className="w-5 h-5" />
@@ -155,7 +161,7 @@ export default function ApprovalList({
{type === 'submission'
? '상신한 결재 목록을 확인합니다.'
: '결재 처리 이력을 확인합니다.'
- } {useFakeData && '(테스트 모드)'}
+ }
</CardDescription>
</CardHeader>
diff --git a/components/knox/approval/ApprovalManager.tsx b/components/knox/approval/ApprovalManager.tsx
index cac534c4..89450445 100644
--- a/components/knox/approval/ApprovalManager.tsx
+++ b/components/knox/approval/ApprovalManager.tsx
@@ -4,8 +4,6 @@ import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
-import { Switch } from '@/components/ui/switch';
-import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { FileText, Eye, XCircle, List, History, Settings } from 'lucide-react';
@@ -16,18 +14,13 @@ import ApprovalCancel from './ApprovalCancel';
import ApprovalList from './ApprovalList';
interface ApprovalManagerProps {
- useFakeData?: boolean;
- systemId?: string;
defaultTab?: string;
}
export default function ApprovalManager({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
defaultTab = 'submit'
}: ApprovalManagerProps) {
const [currentTab, setCurrentTab] = useState(defaultTab);
- const [isTestMode, setIsTestMode] = useState(useFakeData);
const [selectedApInfId, setSelectedApInfId] = useState<string>('');
const handleSubmitSuccess = (apInfId: string) => {
@@ -45,12 +38,8 @@ export default function ApprovalManager({
setCurrentTab('detail');
};
- const handleTestModeChange = (checked: boolean) => {
- setIsTestMode(checked);
- };
-
return (
- <div className="w-full max-w-7xl mx-auto space-y-6">
+ <div className="w-full max-w-5xl mx-auto space-y-6">
{/* 헤더 */}
<Card>
<CardHeader>
@@ -65,26 +54,15 @@ export default function ApprovalManager({
<CardContent>
<div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- <div className="flex items-center gap-2">
- <Label htmlFor="test-mode">테스트 모드</Label>
- <Switch
- id="test-mode"
- checked={isTestMode}
- onCheckedChange={handleTestModeChange}
- />
- </div>
- {isTestMode && (
- <Badge variant="outline" className="text-yellow-600 border-yellow-600">
- 테스트 모드 활성화
- </Badge>
- )}
- </div>
+ {/* 좌측 영역 - 현재는 테스트 모드 UI 제거로 여백 유지 */}
+ <div />
- <div className="flex items-center gap-2 text-sm text-gray-500">
- <span>시스템 ID:</span>
- <Badge variant="outline">{systemId}</Badge>
- </div>
+ {process.env.NEXT_PUBLIC_KNOX_SYSTEM_ID && (
+ <div className="flex items-center gap-2 text-sm text-gray-500">
+ <span>시스템 ID:</span>
+ <Badge variant="outline">{process.env.NEXT_PUBLIC_KNOX_SYSTEM_ID}</Badge>
+ </div>
+ )}
</div>
</CardContent>
</Card>
@@ -116,50 +94,46 @@ export default function ApprovalManager({
{/* 결재 상신 탭 */}
<TabsContent value="submit" className="space-y-6">
- <ApprovalSubmit
- useFakeData={isTestMode}
- systemId={systemId}
- onSubmitSuccess={handleSubmitSuccess}
- />
+ <div className="w-full">
+ <ApprovalSubmit onSubmitSuccess={handleSubmitSuccess} />
+ </div>
</TabsContent>
{/* 결재 상세 조회 탭 */}
<TabsContent value="detail" className="space-y-6">
- <ApprovalDetail
- useFakeData={isTestMode}
- systemId={systemId}
- initialApInfId={selectedApInfId}
- />
+ <div className="w-full">
+ <ApprovalDetail initialApInfId={selectedApInfId} />
+ </div>
</TabsContent>
{/* 결재 취소 탭 */}
<TabsContent value="cancel" className="space-y-6">
- <ApprovalCancel
- useFakeData={isTestMode}
- systemId={systemId}
- initialApInfId={selectedApInfId}
- onCancelSuccess={handleCancelSuccess}
- />
+ <div className="w-full">
+ <ApprovalCancel
+ initialApInfId={selectedApInfId}
+ onCancelSuccess={handleCancelSuccess}
+ />
+ </div>
</TabsContent>
{/* 상신함 탭 */}
<TabsContent value="list" className="space-y-6">
- <ApprovalList
- useFakeData={isTestMode}
- systemId={systemId}
- type="submission"
- onItemClick={handleListItemClick}
- />
+ <div className="w-full">
+ <ApprovalList
+ type="submission"
+ onItemClick={handleListItemClick}
+ />
+ </div>
</TabsContent>
{/* 결재 이력 탭 */}
<TabsContent value="history" className="space-y-6">
- <ApprovalList
- useFakeData={isTestMode}
- systemId={systemId}
- type="history"
- onItemClick={handleListItemClick}
- />
+ <div className="w-full">
+ <ApprovalList
+ type="history"
+ onItemClick={handleListItemClick}
+ />
+ </div>
</TabsContent>
</Tabs>
diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx
index 526a87f3..f3c1fa3d 100644
--- a/components/knox/approval/ApprovalSubmit.tsx
+++ b/components/knox/approval/ApprovalSubmit.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -8,25 +8,93 @@ 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 { Textarea } from '@/components/ui/textarea';
+
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, Plus, Trash2, FileText, AlertCircle } from 'lucide-react';
+import { Loader2, Trash2, FileText, AlertCircle, GripVertical } from 'lucide-react';
+
+// dnd-kit imports for drag and drop
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core';
+
+// 드래그 이동을 수직 축으로 제한하고, 리스트 영역 밖으로 벗어나지 않도록 하는 modifier
+import {
+ restrictToVerticalAxis,
+ restrictToParentElement,
+} from '@dnd-kit/modifiers';
+import {
+ 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';
-// Mock 데이터
-import { mockApprovalAPI, createMockApprovalLine, getRoleText } from './mocks/approval-mock';
+// 역할 텍스트 매핑 (기존 mock util 대체)
+const getRoleText = (role: string) => {
+ 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';
+
+// UserSelector 컴포넌트
+import { UserSelector, type UserSelectItem } from '@/components/common/user/user-selector';
+import { useSession } from 'next-auth/react';
+
+// UserSelector에서 반환되는 사용자에 epId가 포함될 수 있으므로 확장 타입 정의
+interface ExtendedUserSelectItem extends UserSelectItem {
+ epId?: string;
+}
+
+// 역할 코드 타입 정의
+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;
+}
const formSchema = z.object({
subject: z.string().min(1, '제목은 필수입니다'),
contents: z.string().min(1, '내용은 필수입니다'),
- contentsType: z.enum(['TEXT', 'HTML', 'MIME']),
+ contentsType: z.literal('HTML'),
docSecuType: z.enum(['PERSONAL', 'CONFIDENTIAL', 'CONFIDENTIAL_STRICT']),
urgYn: z.boolean(),
importantYn: z.boolean(),
@@ -35,8 +103,12 @@ const formSchema = z.object({
sbmLang: z.enum(['ko', 'ja', 'zh', 'en']),
timeZone: z.string().default('GMT+9'),
aplns: z.array(z.object({
- userId: z.string().min(1, '사용자 ID는 필수입니다'),
+ 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()
@@ -48,25 +120,344 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>;
interface ApprovalSubmitProps {
- useFakeData?: boolean;
- systemId?: string;
onSubmitSuccess?: (apInfId: string) => void;
}
-export default function ApprovalSubmit({
- useFakeData = false,
- systemId = 'EVCP_SYSTEM',
- onSubmitSuccess
-}: ApprovalSubmitProps) {
+// Sortable한 결재 라인 컴포넌트
+interface SortableApprovalLineProps {
+ 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}` : ''}
+ </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>
+ );
+}
+
+// Sortable Approval Group (seq 단위 카드)
+interface SortableApprovalGroupProps {
+ 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}` : ''}
+ </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>
+ );
+}
+
+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: 'TEXT',
+ contentsType: 'HTML',
docSecuType: 'PERSONAL',
urgYn: false,
importantYn: false,
@@ -74,109 +465,342 @@ export default function ApprovalSubmit({
docMngSaveCode: '0',
sbmLang: 'ko',
timeZone: 'GMT+9',
- aplns: [
- {
- userId: '',
- emailAddress: '',
- role: '0',
- seq: '1',
- opinion: ''
- }
- ],
+ aplns: [],
attachments: undefined
}
});
const aplns = form.watch('aplns');
- const addApprovalLine = () => {
- const newSeq = (aplns.length + 1).toString();
- form.setValue('aplns', [...aplns, {
- userId: '',
- emailAddress: '',
- role: '1',
- seq: newSeq,
- opinion: ''
- }]);
+ // 병렬 전환 핸들러
+ 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)! };
+ });
+ };
+
+ // 로그인 사용자를 첫 번째 결재자로 보장하는 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];
+ }
+
+ // 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((_, i) => i !== index);
- // 순서 재정렬
- const reorderedAplns = newAplns.map((apln, i) => ({
+ 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 + 1).toString()
+ 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 {
- // 결재 경로 생성
+ // 결재 경로 생성 (ID 제거하고 API 호출)
const approvalLines: ApprovalLine[] = await Promise.all(
- data.aplns.map(async (apln) => {
- if (useFakeData) {
- return createMockApprovalLine({
- userId: apln.userId,
- emailAddress: apln.emailAddress,
- role: apln.role,
- seq: apln.seq,
- opinion: apln.opinion
- });
- } else {
- return createApprovalLine(
- { userId: apln.userId, emailAddress: apln.emailAddress },
- apln.role,
- apln.seq,
- { opinion: apln.opinion }
- );
- }
- })
+ data.aplns.map((apln) =>
+ createApprovalLine(
+ // userId: apln.userId 는 불필요하므로 제거
+ { epId: apln.epId, emailAddress: apln.emailAddress },
+ apln.role,
+ apln.seq,
+ { opinion: apln.opinion }
+ )
+ )
);
// 상신 요청 생성
const attachmentsArray = data.attachments ? Array.from(data.attachments as FileList) : undefined;
- const submitRequest: SubmitApprovalRequest = useFakeData
- ? {
- ...data,
- urgYn: data.urgYn ? 'Y' : 'N',
- importantYn: data.importantYn ? 'Y' : 'N',
- sbmDt: new Date().toISOString().replace(/-|:|T/g, '').slice(0, 14),
- apInfId: 'test-ap-inf-id-' + Date.now(),
- aplns: approvalLines,
- attachments: attachmentsArray
- }
- : 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
- }
- );
+ const submitRequest: SubmitApprovalRequest = await createSubmitApprovalRequest(
+ data.contents,
+ data.subject,
+ approvalLines,
+ {
+ contentsType: 'HTML',
+ 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
+ }
+ );
// API 호출 (보안 등급에 따라 분기)
const isSecure = data.docSecuType === 'CONFIDENTIAL' || data.docSecuType === 'CONFIDENTIAL_STRICT';
- const response = useFakeData
- ? await mockApprovalAPI.submitApproval(submitRequest)
- : isSecure
- ? await submitSecurityApproval(submitRequest, systemId)
- : await submitApproval(submitRequest, systemId);
+ console.log(submitRequest);
+
+ const response = isSecure
+ ? await submitSecurityApproval(submitRequest)
+ : await submitApproval(submitRequest);
if (response.result === 'SUCCESS') {
setSubmitResult({ apInfId: response.data.apInfId });
@@ -195,14 +819,14 @@ export default function ApprovalSubmit({
};
return (
- <Card className="w-full max-w-4xl">
+ <Card className="w-full max-w-5xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
결재 상신
</CardTitle>
<CardDescription>
- 새로운 결재를 상신합니다. {useFakeData && '(테스트 모드)'}
+ 새로운 결재를 상신합니다.
</CardDescription>
</CardHeader>
@@ -223,7 +847,80 @@ export default function ApprovalSubmit({
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* 기본 정보 */}
<div className="space-y-4">
- <h3 className="text-lg font-semibold">기본 정보</h3>
+
+ <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>
+
+ {/* 그룹 기반 렌더링 */}
+ {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}
@@ -246,10 +943,10 @@ export default function ApprovalSubmit({
<FormItem>
<FormLabel>내용 *</FormLabel>
<FormControl>
- <Textarea
- placeholder="결재 내용을 입력하세요"
- rows={8}
- {...field}
+ <RichTextEditor
+ value={field.value}
+ onChange={field.onChange}
+ height="400px"
/>
</FormControl>
<FormMessage />
@@ -257,64 +954,38 @@ export default function ApprovalSubmit({
)}
/>
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="contentsType"
- render={({ field }) => (
- <FormItem>
- <FormLabel>내용 형식</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="내용 형식 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="TEXT">TEXT</SelectItem>
- <SelectItem value="HTML">HTML</SelectItem>
- <SelectItem value="MIME">MIME</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
+ {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */}
+ <div className="grid grid-cols-3 gap-4">
+ {/* 보안 등급 */}
<FormField
control={form.control}
name="docSecuType"
render={({ field }) => (
- <FormItem>
- <FormLabel>보안 등급</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="보안 등급 선택" />
+ <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>
- </FormControl>
- <SelectContent>
- <SelectItem value="PERSONAL">개인</SelectItem>
- <SelectItem value="CONFIDENTIAL">기밀</SelectItem>
- <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
+ <SelectContent>
+ <SelectItem value="PERSONAL">개인</SelectItem>
+ <SelectItem value="CONFIDENTIAL">기밀</SelectItem>
+ <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem>
+ </SelectContent>
+ </Select>
+ </FormControl>
</FormItem>
)}
/>
- </div>
- <div className="grid grid-cols-2 gap-4">
+ {/* 긴급 여부 */}
<FormField
control={form.control}
name="urgYn"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel>긴급 여부</FormLabel>
- <FormDescription>긴급 결재로 처리</FormDescription>
- </div>
+ <FormLabel>긴급</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -325,15 +996,13 @@ export default function ApprovalSubmit({
)}
/>
+ {/* 중요 여부 */}
<FormField
control={form.control}
name="importantYn"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
- <div className="space-y-0.5">
- <FormLabel>중요 여부</FormLabel>
- <FormDescription>중요 결재로 분류</FormDescription>
- </div>
+ <FormLabel>중요</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -369,116 +1038,6 @@ export default function ApprovalSubmit({
<Separator />
- {/* 결재 경로 */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-lg font-semibold">결재 경로</h3>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={addApprovalLine}
- >
- <Plus className="w-4 h-4 mr-2" />
- 결재자 추가
- </Button>
- </div>
-
- <div className="space-y-3">
- {aplns.map((apln, index) => (
- <div key={index} className="flex items-center gap-3 p-3 border rounded-lg">
- <Badge variant="outline">{index + 1}</Badge>
-
- <div className="flex-1 grid grid-cols-4 gap-3">
- <FormField
- control={form.control}
- name={`aplns.${index}.userId`}
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Input placeholder="사용자 ID" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`aplns.${index}.emailAddress`}
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Input placeholder="이메일 (선택)" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`aplns.${index}.role`}
- render={({ field }) => (
- <FormItem>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="0">기안</SelectItem>
- <SelectItem value="1">결재</SelectItem>
- <SelectItem value="2">합의</SelectItem>
- <SelectItem value="3">후결</SelectItem>
- <SelectItem value="4">병렬합의</SelectItem>
- <SelectItem value="7">병렬결재</SelectItem>
- <SelectItem value="9">통보</SelectItem>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`aplns.${index}.opinion`}
- render={({ field }) => (
- <FormItem>
- <FormControl>
- <Input placeholder="의견 (선택)" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <div className="flex items-center gap-2">
- <Badge variant="secondary">
- {getRoleText(apln.role)}
- </Badge>
-
- {aplns.length > 1 && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeApprovalLine(index)}
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- )}
- </div>
- </div>
- ))}
- </div>
- </div>
-
- <Separator />
-
{/* 제출 버튼 */}
<div className="flex justify-end space-x-3">
<Button
diff --git a/components/knox/approval/index.ts b/components/knox/approval/index.ts
index 0bae08f1..e3aabf8d 100644
--- a/components/knox/approval/index.ts
+++ b/components/knox/approval/index.ts
@@ -5,9 +5,6 @@ export { default as ApprovalCancel } from './ApprovalCancel';
export { default as ApprovalList } from './ApprovalList';
export { default as ApprovalManager } from './ApprovalManager';
-// Mock 데이터 및 유틸리티 함수들
-export * from './mocks/approval-mock';
-
// 타입 정의들 (re-export)
export type {
ApprovalLine,
diff --git a/components/knox/approval/mocks/approval-mock.ts b/components/knox/approval/mocks/approval-mock.ts
deleted file mode 100644
index 021eb925..00000000
--- a/components/knox/approval/mocks/approval-mock.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-import {
- ApprovalLine,
- SubmitApprovalRequest,
- SubmitApprovalResponse,
- ApprovalDetailResponse,
- ApprovalContentResponse,
- ApprovalStatusResponse,
- CancelApprovalResponse,
- SubmissionListResponse,
- ApprovalHistoryResponse
-} from '@/lib/knox-api/approval/approval';
-
-// Mock 데이터 생성 함수들
-export const createMockApprovalLine = (overrides?: Partial<ApprovalLine>): ApprovalLine => ({
- epId: '12345',
- userId: 'user123',
- emailAddress: 'user@example.com',
- seq: '1',
- role: '0', // 기안
- aplnStatsCode: '0', // 미결
- arbPmtYn: 'N',
- contentsMdfyPmtYn: 'N',
- aplnMdfyPmtYn: 'N',
- opinion: '결재 요청드립니다.',
- ...overrides
-});
-
-export const createMockSubmitApprovalRequest = (overrides?: Partial<SubmitApprovalRequest>): SubmitApprovalRequest => ({
- contents: '결재 요청 내용입니다.',
- contentsType: 'TEXT',
- docSecuType: 'PERSONAL',
- notifyOption: '0',
- urgYn: 'N',
- sbmDt: '20241215120000',
- timeZone: 'GMT+9',
- docMngSaveCode: '0',
- subject: '결재 요청 - 테스트',
- sbmLang: 'ko',
- apInfId: 'test-ap-inf-id-' + Date.now(),
- importantYn: 'N',
- aplns: [
- createMockApprovalLine({ seq: '1', role: '0' }), // 기안
- createMockApprovalLine({ seq: '2', role: '1', userId: 'approver1' }), // 결재
- createMockApprovalLine({ seq: '3', role: '1', userId: 'approver2' }), // 결재
- ],
- ...overrides
-});
-
-export const mockSubmitApprovalResponse: SubmitApprovalResponse = {
- result: 'SUCCESS',
- data: {
- apInfId: 'test-ap-inf-id-' + Date.now()
- }
-};
-
-export const mockApprovalDetailResponse: ApprovalDetailResponse = {
- result: 'SUCCESS',
- data: {
- contentsType: 'TEXT',
- sbmDt: '20241215120000',
- sbmLang: 'ko',
- apInfId: 'test-ap-inf-id-123',
- systemId: 'EVCP_SYSTEM',
- notifyOption: '0',
- urgYn: 'N',
- docSecuType: 'PERSONAL',
- status: '1', // 진행중
- timeZone: 'GMT+9',
- subject: '결재 요청 - 테스트',
- aplns: [
- createMockApprovalLine({ seq: '1', role: '0', aplnStatsCode: '1' }), // 기안 완료
- createMockApprovalLine({ seq: '2', role: '1', aplnStatsCode: '0', userId: 'approver1' }), // 결재 대기
- createMockApprovalLine({ seq: '3', role: '1', aplnStatsCode: '0', userId: 'approver2' }), // 결재 대기
- ],
- attachments: []
- }
-};
-
-export const mockApprovalContentResponse: ApprovalContentResponse = {
- result: 'SUCCESS',
- data: {
- contents: '결재 요청 내용입니다.\n\n상세한 내용은 다음과 같습니다:\n- 항목 1\n- 항목 2\n- 항목 3',
- contentsType: 'TEXT',
- apInfId: 'test-ap-inf-id-123'
- }
-};
-
-export const mockApprovalStatusResponse: ApprovalStatusResponse = {
- result: 'SUCCESS',
- data: [
- {
- apInfId: 'test-ap-inf-id-123',
- docChgNum: '1',
- status: '1' // 진행중
- }
- ]
-};
-
-export const mockCancelApprovalResponse: CancelApprovalResponse = {
- result: 'SUCCESS',
- data: {
- apInfId: 'test-ap-inf-id-123'
- }
-};
-
-export const mockSubmissionListResponse: SubmissionListResponse = {
- result: 'SUCCESS',
- data: [
- {
- apInfId: 'test-ap-inf-id-123',
- subject: '결재 요청 - 테스트',
- sbmDt: '20241215120000',
- status: '1',
- urgYn: 'N',
- docSecuType: 'PERSONAL'
- },
- {
- apInfId: 'test-ap-inf-id-124',
- subject: '결재 요청 - 테스트 2',
- sbmDt: '20241214100000',
- status: '2',
- urgYn: 'Y',
- docSecuType: 'CONFIDENTIAL'
- }
- ]
-};
-
-export const mockApprovalHistoryResponse: ApprovalHistoryResponse = {
- result: 'SUCCESS',
- data: [
- {
- apInfId: 'test-ap-inf-id-123',
- subject: '결재 요청 - 테스트',
- sbmDt: '20241215120000',
- status: '1',
- actionType: 'SUBMIT',
- actionDt: '20241215120000',
- userId: 'submitter123'
- },
- {
- apInfId: 'test-ap-inf-id-124',
- subject: '결재 요청 - 테스트 2',
- sbmDt: '20241214100000',
- status: '2',
- actionType: 'APPROVE',
- actionDt: '20241214150000',
- userId: 'approver1'
- }
- ]
-};
-
-// Mock 함수들
-export const mockApprovalAPI = {
- submitApproval: async (request: SubmitApprovalRequest): Promise<SubmitApprovalResponse> => {
- // 실제 API 호출 시뮬레이션
- await new Promise(resolve => setTimeout(resolve, 1000));
- return mockSubmitApprovalResponse;
- },
-
- getApprovalDetail: async (apInfId: string): Promise<ApprovalDetailResponse> => {
- await new Promise(resolve => setTimeout(resolve, 500));
- return mockApprovalDetailResponse;
- },
-
- getApprovalContent: async (apInfId: string): Promise<ApprovalContentResponse> => {
- await new Promise(resolve => setTimeout(resolve, 300));
- return mockApprovalContentResponse;
- },
-
- getApprovalStatus: async (apInfIds: string[]): Promise<ApprovalStatusResponse> => {
- await new Promise(resolve => setTimeout(resolve, 400));
- return mockApprovalStatusResponse;
- },
-
- cancelApproval: async (apInfId: string): Promise<CancelApprovalResponse> => {
- await new Promise(resolve => setTimeout(resolve, 800));
- return mockCancelApprovalResponse;
- },
-
- getSubmissionList: async (): Promise<SubmissionListResponse> => {
- await new Promise(resolve => setTimeout(resolve, 600));
- return mockSubmissionListResponse;
- },
-
- getApprovalHistory: async (): Promise<ApprovalHistoryResponse> => {
- await new Promise(resolve => setTimeout(resolve, 700));
- return mockApprovalHistoryResponse;
- }
-};
-
-// 상태 및 역할 텍스트 변환 함수들
-export const getStatusText = (status: string): string => {
- const statusMap: Record<string, string> = {
- '-3': '암호화실패',
- '-2': '암호화중',
- '-1': '예약상신',
- '0': '보류',
- '1': '진행중',
- '2': '완결',
- '3': '반려',
- '4': '상신취소',
- '5': '전결',
- '6': '후완결'
- };
- return statusMap[status] || '알 수 없음';
-};
-
-export const getRoleText = (role: string): string => {
- const roleMap: Record<string, string> = {
- '0': '기안',
- '1': '결재',
- '2': '합의',
- '3': '후결',
- '4': '병렬합의',
- '7': '병렬결재',
- '9': '통보'
- };
- return roleMap[role] || '알 수 없음';
-};
-
-export const getApprovalStatusText = (status: string): string => {
- const statusMap: Record<string, string> = {
- '0': '미결',
- '1': '결재',
- '2': '반려',
- '3': '전결',
- '5': '자동결재'
- };
- return statusMap[status] || '알 수 없음';
-}; \ No newline at end of file
diff --git a/components/qna/tiptap-editor.tsx b/components/qna/tiptap-editor.tsx
index 4ab7f097..5d0a84e9 100644
--- a/components/qna/tiptap-editor.tsx
+++ b/components/qna/tiptap-editor.tsx
@@ -135,22 +135,6 @@ export default function TiptapEditor({ content, setContent, disabled, height = "
},
})
- // 이미지 크기 확인 함수
- const getImageDimensions = (src: string): Promise<{ width: number; height: number }> =>
- new Promise((resolve, reject) => {
- // 1. 올바른 생성자
- const img = new Image();
-
- // 2. 로딩 완료 시 naturalWidth/Height 사용
- img.onload = () => {
- resolve({ width: img.naturalWidth, height: img.naturalHeight });
- };
-
- img.onerror = () => reject(new Error('이미지 로드 실패'));
- img.src = src; // 3. 마지막에 src 지정
- });
-
-
async function uploadImageToServer(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
diff --git a/components/rich-text-editor/RichTextEditor.tsx b/components/rich-text-editor/RichTextEditor.tsx
new file mode 100644
index 00000000..ceb76665
--- /dev/null
+++ b/components/rich-text-editor/RichTextEditor.tsx
@@ -0,0 +1,998 @@
+'use client'
+
+import React, { useCallback, useRef, useState, 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 Highlight from '@tiptap/extension-highlight'
+import TaskList from '@tiptap/extension-task-list'
+import TaskItem from '@tiptap/extension-task-item'
+import BulletList from '@tiptap/extension-bullet-list'
+import ListItem from '@tiptap/extension-list-item'
+import OrderedList from '@tiptap/extension-ordered-list'
+import Blockquote from '@tiptap/extension-blockquote'
+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'
+
+// 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'
+
+/* -------------------------------------------------------------------------------------------------
+ * 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%"
+}
+
+export default function RichTextEditor({
+ value,
+ onChange,
+ disabled,
+ height = '300px',
+}: RichTextEditorProps) {
+ // ---------------------------------------------------------------------------
+ // Editor instance
+ // ---------------------------------------------------------------------------
+ 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,
+ ],
+ content: value,
+ editable: !disabled,
+ enablePasteRules: false,
+ enableInputRules: false,
+ immediatelyRender: false,
+ 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',
+ },
+ handleDrop: (view, event, slice, moved) => {
+ if (!moved && event.dataTransfer?.files.length) {
+ const file = event.dataTransfer.files[0]
+ if (file.type.startsWith('image/')) {
+ handleImageUpload(file)
+ return true
+ }
+ }
+ return false
+ },
+ handlePaste: (view, event) => {
+ if (event.clipboardData?.files.length) {
+ const file = event.clipboardData.files[0]
+ if (file.type.startsWith('image/')) {
+ handleImageUpload(file)
+ return true
+ }
+ }
+ return false
+ },
+ },
+ onUpdate: ({ editor }) => {
+ onChange(editor.getHTML())
+ },
+ })
+
+ // ---------------------------------------------------------------------------
+ // Image handling (base64)
+ // ---------------------------------------------------------------------------
+ const handleImageUpload = async (file: File) => {
+ if (file.size > 3 * 1024 * 1024) {
+ alert('이미지 크기는 3 MB 이하만 지원됩니다.')
+ return
+ }
+ if (!file.type.startsWith('image/')) {
+ alert('이미지 파일만 업로드 가능합니다.')
+ 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()
+ }
+ 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)` }
+
+ 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>
+ <div className="overflow-y-auto" style={editorContentStyle}>
+ <EditorContent editor={editor} className="h-full" />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/spread-js/dataBinding.tsx b/components/spread-js/dataBinding.tsx
index 52171dbf..b619f9f2 100644
--- a/components/spread-js/dataBinding.tsx
+++ b/components/spread-js/dataBinding.tsx
@@ -7,8 +7,8 @@ import "@mescius/spread-sheets-resources-ko";
import { SpreadSheets } from "@mescius/spread-sheets-react";
GC.Spread.Common.CultureManager.culture("ko-kr");
-GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREADJS_KEY
-GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_SPREADJS_KEY
+GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE
+GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_DESIGNER_LICENSE
const DataBinding = () => {
let spread = null;
diff --git a/components/spread-js/testSheet.tsx b/components/spread-js/testSheet.tsx
index 0d69798e..02347b00 100644
--- a/components/spread-js/testSheet.tsx
+++ b/components/spread-js/testSheet.tsx
@@ -31,8 +31,8 @@ import { Button } from "@/components/ui/button";
// var SpreadJSKey = "xxx"; // 라이선스 키 입력
// GC.Spread.Sheets.LicenseKey = SpreadJSKey;
GC.Spread.Common.CultureManager.culture("ko-kr");
-GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREADJS_KEY
-GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_SPREADJS_KEY
+GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE
+GC.Spread.Sheets.Designer.LicenseKey = process.env.NEXT_PUBLIC_DESIGNER_LICENSE
export default function SpreadSheet() {
const [spread, setSpread] = useState(null);
diff --git a/db/schema/index.ts b/db/schema/index.ts
index c48f0a8b..87ea224a 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -47,7 +47,8 @@ export * from './NONSAP/nonsap';
// ECC SOAP 수신용 (RFQ, PO, PR 데이터)
export * from './ECC/ecc';
-// Knox 임직원, 조직도, 직급
-export * from './knox/employee';
-export * from './knox/organization';
-export * from './knox/titles'; \ No newline at end of file
+// === Knox 스키마 ===
+export * from './knox/employee'; // 임직원
+export * from './knox/organization'; // 조직도
+export * from './knox/titles'; // 직급
+export * from './knox/approvals'; // Knox 결재 - eVCP 에서 상신한 결재를 저장 \ No newline at end of file
diff --git a/db/schema/knox/approvals.ts b/db/schema/knox/approvals.ts
new file mode 100644
index 00000000..27332ed6
--- /dev/null
+++ b/db/schema/knox/approvals.ts
@@ -0,0 +1,16 @@
+import { boolean, jsonb, text, timestamp, } from "drizzle-orm/pg-core";
+import { knoxSchema } from "./employee";
+
+export const approval = knoxSchema.table("approval", {
+ apInfId: text("ap_inf_id").primaryKey(),
+ userId: text("user_id").notNull(),
+ epId: text("ep_id").notNull(),
+ emailAddress: text("email_address").notNull(),
+ subject: text("subject").notNull(),
+ content: text("content").notNull(),
+ status: text("status").notNull(),
+ aplns: jsonb("aplns").notNull(),
+ 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
diff --git a/db/schema/users.ts b/db/schema/users.ts
index bf5d41de..5ea399b4 100644
--- a/db/schema/users.ts
+++ b/db/schema/users.ts
@@ -13,6 +13,7 @@ export const users = pgTable("users", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
name: varchar("name", { length: 255 }).notNull(),
email: varchar("email", { length: 255 }).notNull().unique(),
+ epId: varchar("epId", { length: 50 }), // Knox Unique Id (PK)
deptCode: varchar("deptCode", { length: 50 }),
deptName: varchar("deptName", { length: 255 }),
diff --git a/lib/knox-api/approval/approval.ts b/lib/knox-api/approval/approval.ts
index 75066478..5e62382d 100644
--- a/lib/knox-api/approval/approval.ts
+++ b/lib/knox-api/approval/approval.ts
@@ -1,6 +1,8 @@
"use server"
import { getKnoxConfig, createJsonHeaders, createFormHeaders } from '../common';
+import { randomUUID } from 'crypto';
+import { saveApprovalToDatabase, deleteApprovalFromDatabase } from './service';
// Knox API Approval 서버 액션들
// 가이드: lib/knox-api/approval/guide.html
@@ -15,7 +17,7 @@ export interface BaseResponse {
// 결재 경로 타입
export interface ApprovalLine {
epId?: string;
- userId?: string;
+ userId?: string; // eVCP ID라서 사용하지 않음!
emailAddress?: string;
seq: string;
role: string; // 기안(0), 결재(1), 합의(2), 후결(3), 병렬합의(4), 병렬결재(7), 통보(9)
@@ -132,7 +134,8 @@ export interface ApprovalIdsResponse extends BaseResponse {
* POST /approval/api/v2.0/approvals/submit
*/
export async function submitApproval(
- request: SubmitApprovalRequest
+ request: SubmitApprovalRequest,
+ userInfo: { userId: string; epId: string; emailAddress: string }
): Promise<SubmitApprovalResponse> {
try {
const config = await getKnoxConfig();
@@ -149,8 +152,8 @@ export async function submitApproval(
timeZone: request.timeZone,
docMngSaveCode: request.docMngSaveCode,
subject: request.subject,
- sbmLang: request.sbmLang,
- apInfId: request.apInfId,
+ sbmLang: request.sbmLang || 'ko',
+ apInfId: request.apInfId, // 고정값, 환경변수로 설정해 common 에서 가져오기
importantYn: request.importantYn,
aplns: request.aplns
};
@@ -174,7 +177,28 @@ export async function submitApproval(
throw new Error(`결재 상신 실패: ${response.status}`);
}
- return await response.json();
+ const result = await response.json();
+
+ // Knox API 성공 시 데이터베이스에 저장
+ if (result.result === 'SUCCESS') {
+ try {
+ await saveApprovalToDatabase(
+ request.apInfId,
+ userInfo.userId,
+ userInfo.epId,
+ userInfo.emailAddress,
+ request.subject,
+ request.contents,
+ request.aplns
+ );
+ } catch (dbError) {
+ console.error('데이터베이스 저장 실패:', dbError);
+ // 데이터베이스 저장 실패는 Knox API 성공을 무효화하지 않음
+ // 필요시 별도 처리 로직 추가
+ }
+ }
+
+ return result;
} catch (error) {
console.error('결재 상신 오류:', error);
throw error;
@@ -426,7 +450,20 @@ export async function cancelApproval(
throw new Error(`상신 취소 실패: ${response.status}`);
}
- return await response.json();
+ const result = await response.json();
+
+ // Knox API 성공 시 데이터베이스에서 삭제
+ if (result.result === 'SUCCESS') {
+ try {
+ await deleteApprovalFromDatabase(apInfId);
+ } catch (dbError) {
+ console.error('데이터베이스 삭제 실패:', dbError);
+ // 데이터베이스 삭제 실패는 Knox API 성공을 무효화하지 않음
+ // 필요시 별도 처리 로직 추가
+ }
+ }
+
+ return result;
} catch (error) {
console.error('상신 취소 오류:', error);
throw error;
@@ -501,10 +538,29 @@ export async function createSubmitApprovalRequest(
approvalLines: ApprovalLine[],
options: Partial<SubmitApprovalRequest> = {}
): Promise<SubmitApprovalRequest> {
- const config = await getKnoxConfig();
+
+ // 요구하는 날짜 형식으로 변환 (YYYYMMDDHHMMSS) - UTC 기준 (타임존 정보를 GTC+9 로 제공하고 있음)
const now = new Date();
- const sbmDt = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
- const apInfId = `${config.systemId}${sbmDt}${Math.random().toString(36).substr(2, 9)}`.padEnd(32, '0');
+ const formatter = new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'UTC',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ });
+
+ const parts = formatter.formatToParts(now);
+ const sbmDt = parts
+ .filter((part) => part.type !== 'literal')
+ .map((part) => part.value)
+ .join('');
+
+
+ // EVCP 접두어 뒤에 28자리 무작위 문자열을 붙여 32byte 고유 ID 생성
+ const apInfId = `EVCP${randomUUID().replace(/-/g, '').slice(0, 28)}`;
return {
contents,
diff --git a/lib/knox-api/approval/service.ts b/lib/knox-api/approval/service.ts
new file mode 100644
index 00000000..6ef1b1f6
--- /dev/null
+++ b/lib/knox-api/approval/service.ts
@@ -0,0 +1,140 @@
+"use server"
+import db from '@/db/db';
+import { ApprovalLine } from "./approval";
+import { approval } from '@/db/schema/knox/approvals';
+import { eq, and } from 'drizzle-orm';
+
+// ========== 데이터베이스 서비스 함수들 ==========
+
+
+
+/**
+ * 결재 상신 데이터를 데이터베이스에 저장
+ */
+export async function saveApprovalToDatabase(
+ apInfId: string,
+ userId: string,
+ epId: string,
+ emailAddress: string,
+ subject: string,
+ content: string,
+ aplns: ApprovalLine[]
+): Promise<void> {
+ try {
+ await db.insert(approval).values({
+ apInfId,
+ userId,
+ epId,
+ emailAddress,
+ subject,
+ content,
+ status: '1', // 진행중 상태로 초기 설정
+ aplns,
+ isDeleted: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ } catch (error) {
+ console.error('결재 데이터 저장 실패:', error);
+ throw new Error(
+ '결재 데이터를 데이터베이스에 저장하는 중 오류가 발생했습니다.'
+ );
+ }
+}
+
+/**
+ * 결재 상태 업데이트
+ */
+export async function updateApprovalStatus(
+ apInfId: string,
+ status: string
+): Promise<void> {
+ try {
+ await db
+ .update(approval)
+ .set({
+ status,
+ updatedAt: new Date(),
+ })
+ .where(eq(approval.apInfId, apInfId));
+ } catch (error) {
+ console.error('결재 상태 업데이트 실패:', error);
+ throw new Error('결재 상태를 업데이트하는 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 결재 상세 정보 조회
+ */
+export async function getApprovalFromDatabase(
+ apInfId: string,
+ includeDeleted: boolean = false
+): Promise<typeof approval.$inferSelect | null> {
+ try {
+ const whereCondition = includeDeleted
+ ? eq(approval.apInfId, apInfId)
+ : and(eq(approval.apInfId, apInfId), eq(approval.isDeleted, false));
+
+ const result = await db
+ .select()
+ .from(approval)
+ .where(whereCondition)
+ .limit(1);
+
+ return result[0] || null;
+ } catch (error) {
+ console.error('결재 데이터 조회 실패:', error);
+ throw new Error('결재 데이터를 조회하는 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 사용자별 결재 목록 조회
+ */
+export async function getApprovalsByUser(
+ userId: string,
+ limit: number = 50,
+ offset: number = 0,
+ includeDeleted: boolean = false
+): Promise<typeof approval.$inferSelect[]> {
+ try {
+ const whereCondition = includeDeleted
+ ? eq(approval.userId, userId)
+ : and(eq(approval.userId, userId), eq(approval.isDeleted, false));
+
+ const result = await db
+ .select()
+ .from(approval)
+ .where(whereCondition)
+ .orderBy(approval.createdAt)
+ .limit(limit)
+ .offset(offset);
+
+ return result;
+ } catch (error) {
+ console.error('사용자 결재 목록 조회 실패:', error);
+ throw new Error('사용자 결재 목록을 조회하는 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 결재 삭제 (상신 취소 시) - Soft Delete
+ */
+export async function deleteApprovalFromDatabase(
+ apInfId: string
+): Promise<void> {
+ try {
+ await db
+ .update(approval)
+ .set({
+ isDeleted: true,
+ updatedAt: new Date(),
+ })
+ .where(eq(approval.apInfId, apInfId));
+ } catch (error) {
+ console.error('결재 데이터 삭제 실패:', error);
+ throw new Error('결재 데이터를 삭제하는 중 오류가 발생했습니다.');
+ }
+}
+
+
diff --git a/lib/knox-api/common.ts b/lib/knox-api/common.ts
index 4c037e56..db6910f2 100644
--- a/lib/knox-api/common.ts
+++ b/lib/knox-api/common.ts
@@ -6,6 +6,7 @@
export interface KnoxConfig {
baseUrl: string;
systemId: string;
+ apInfId?: string; // 환경변수에서 주입 (고정값)
bearerToken: string;
}
diff --git a/lib/knox-sync/employee-sync-service.ts b/lib/knox-sync/employee-sync-service.ts
index 3e8b048e..b7f2a323 100644
--- a/lib/knox-sync/employee-sync-service.ts
+++ b/lib/knox-sync/employee-sync-service.ts
@@ -198,6 +198,7 @@ async function syncEmployeesToUsers(): Promise<void> {
departmentCode: employeeTable.departmentCode,
departmentName: employeeTable.departmentName,
companyCode: employeeTable.companyCode,
+ epId: employeeTable.epId,
})
.from(employeeTable)
.where(
@@ -271,6 +272,7 @@ async function syncEmployeesToUsers(): Promise<void> {
deptCode: employee.departmentCode,
deptName: employee.departmentName,
domain: assignedDomain as UserDomainType,
+ epId: employee.epId,
updatedAt: new Date(),
})
.where(eq(users.id, existingUsers[0].id));
@@ -295,6 +297,7 @@ async function syncEmployeesToUsers(): Promise<void> {
deptCode: employee.departmentCode,
deptName: employee.departmentName,
domain: assignedDomain as UserDomainType,
+ epId: employee.epId,
});
insertCount++;
diff --git a/lib/knox-sync/master-sync-service.ts b/lib/knox-sync/master-sync-service.ts
index 5cabe9ed..ed77a3fd 100644
--- a/lib/knox-sync/master-sync-service.ts
+++ b/lib/knox-sync/master-sync-service.ts
@@ -67,6 +67,6 @@ export async function startKnoxMasterSyncScheduler() {
syncAllKnoxData().catch(console.error);
});
+ // 직급 정보 기반으로, 각 직급마다 임직원 조회 (Knox API 구조상 제한으로 직급 및 부서마다 조회 가능하며, 직급이 수가 더 적음(600 vs 2400))
logSchedulerInfo('통합(직급→조직→임직원)', CRON_STRING);
- console.log('[KNOX-SYNC] 💡 순차 실행으로 의존성 문제 해결 (직급 완료 → 조직 완료 → 임직원 완료)');
} \ No newline at end of file
diff --git a/lib/users/service.ts b/lib/users/service.ts
index 80c346fa..90ee170e 100644
--- a/lib/users/service.ts
+++ b/lib/users/service.ts
@@ -13,7 +13,7 @@ import db from "@/db/db";
import { getErrorMessage } from "@/lib/handle-error";
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray, ne } from "drizzle-orm";
+import { and, or, desc, asc, ilike, eq, isNull, isNotNull, sql, count, inArray, ne, not } from "drizzle-orm";
import { SaveFileResult, saveFile } from '../file-stroage';
interface AssignUsersArgs {
@@ -1012,3 +1012,96 @@ export async function getUserRoles(userId: number): Promise<string[]> {
}
}
+/**
+ * 사용자 선택기용 간단한 사용자 검색 함수
+ */
+export async function searchUsersForSelector(
+ query: string,
+ page: number = 1,
+ perPage: number = 10,
+ domainFilter?: { type: "exclude" | "include"; domains: string[] } | null
+) {
+ try {
+ const offset = (page - 1) * perPage;
+
+ // 이름 검색 조건
+ let searchWhere;
+ if (query.trim()) {
+ const searchPattern = `%${query.trim()}%`;
+ searchWhere = ilike(users.name, searchPattern);
+ }
+
+ // 도메인 필터 조건
+ let domainWhere;
+ if (domainFilter && domainFilter.domains.length > 0) {
+ if (domainFilter.type === "include") {
+ domainWhere = inArray(users.domain, domainFilter.domains);
+ } else if (domainFilter.type === "exclude") {
+ domainWhere = not(inArray(users.domain, domainFilter.domains));
+ }
+ }
+
+ // 활성 사용자만
+ const activeWhere = eq(users.isActive, true);
+
+ // 최종 조건
+ const finalWhere = and(searchWhere, domainWhere, activeWhere);
+
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ id: users.id,
+ epId: users.epId,
+ name: users.name,
+ email: users.email,
+ deptCode: users.deptCode,
+ deptName: users.deptName,
+ imageUrl: users.imageUrl,
+ domain: users.domain,
+ })
+ .from(users)
+ .where(finalWhere)
+ .orderBy(asc(users.name))
+ .limit(perPage)
+ .offset(offset);
+
+ const totalResult = await tx
+ .select({ count: count() })
+ .from(users)
+ .where(finalWhere);
+
+ return { data, total: totalResult[0].count };
+ });
+
+ const pageCount = Math.ceil(total / perPage);
+
+ return {
+ success: true,
+ data,
+ pagination: {
+ page,
+ perPage,
+ total,
+ pageCount,
+ hasNextPage: page < pageCount,
+ hasPrevPage: page > 1,
+ },
+ };
+ } catch (error) {
+ console.error("사용자 검색 오류:", error);
+ return {
+ success: false,
+ data: [],
+ pagination: {
+ page: 1,
+ perPage,
+ total: 0,
+ pageCount: 0,
+ hasNextPage: false,
+ hasPrevPage: false,
+ },
+ error: "사용자 검색 중 오류가 발생했습니다.",
+ };
+ }
+}
+