diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-28 12:10:39 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-28 12:10:39 +0000 |
| commit | 75249e6fa46864f49d4eb91bd755171b6b65eaae (patch) | |
| tree | f2c021f0fe10b3513d29f05ca15b82e460d79d20 | |
| parent | c228a89c2834ee63b209bad608837c39643f350e (diff) | |
(김준회) 공통모듈 - Knox 결재 모듈 구현, 유저 선택기 구현, 상신 결재 저장을 위한 DB 스키마 및 서비스 추가, spreadjs 라이센스 환경변수 통일, 유저 테이블에 epId 컬럼 추가
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 '<'; - case '>': return '>'; - case '&': return '&'; - case '"': return '"'; - case "'": return '''; - 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: "사용자 검색 중 오류가 발생했습니다.", + }; + } +} + |
