diff options
60 files changed, 7196 insertions, 2124 deletions
diff --git a/app/[lng]/admin/approval-test/page.tsx b/app/[lng]/admin/approval-test/page.tsx index ab5654f3..439c7ba8 100644 --- a/app/[lng]/admin/approval-test/page.tsx +++ b/app/[lng]/admin/approval-test/page.tsx @@ -1,12 +1,17 @@ import { Metadata } from 'next'; import ApprovalManager from '@/components/knox/approval/ApprovalManager'; +import { findUserByEmail } from '@/lib/users/service'; +import { getServerSession } from 'next-auth/next'; export const metadata: Metadata = { title: 'Knox 결재 시스템 | Admin', description: 'Knox API를 사용한 결재 시스템', }; -export default function ApprovalTestPage() { +export default async function ApprovalTestPage() { + const session = await getServerSession(); + const currentUser = await findUserByEmail(session?.user?.email ?? ''); + return ( <div className="container mx-auto py-8"> <div className="space-y-6"> @@ -21,6 +26,7 @@ export default function ApprovalTestPage() { {/* 결재 관리자 컴포넌트 */} <ApprovalManager defaultTab="submit" + currentUser={currentUser} /> </div> </div> diff --git a/app/[lng]/evcp/(evcp)/approval/line/page.tsx b/app/[lng]/evcp/(evcp)/approval/line/page.tsx new file mode 100644 index 00000000..435d1071 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/approval/line/page.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { type Metadata } from 'next'; +import { Shell } from '@/components/shell'; +import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton'; +import { type SearchParams } from '@/types/table'; +import { getValidFilters } from '@/lib/data-table'; + +import { getApprovalLineList } from '@/lib/approval-line/service'; +import { SearchParamsApprovalLineCache } from '@/lib/approval-line/validations'; +import { ApprovalLineTable } from '@/lib/approval-line/table/approval-line-table'; + +export const metadata: Metadata = { + title: '결재선 관리', + description: '결재용 결재선을 관리합니다.', +}; + +interface PageProps { + searchParams: SearchParams; +} + +export default async function ApprovalLinePage({ searchParams }: PageProps) { + const search = SearchParamsApprovalLineCache.parse(searchParams); + // getValidFilters 반환값이 undefined 인 경우 폴백 + const validFilters = getValidFilters(search.filters) ?? []; + + const promises = Promise.all([ + getApprovalLineList({ + ...search, + filters: validFilters, + }), + ]); + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight">결재선 관리</h2> + </div> + </div> + </div> + </div> + + {/* 테이블 */} + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={[ + '10rem', + '20rem', + '30rem', + '12rem', + '12rem', + '8rem', + ]} + shrinkZero + /> + } + > + <ApprovalLineTable promises={promises} /> + </React.Suspense> + </Shell> + ); +} diff --git a/app/[lng]/evcp/(evcp)/approval/template/[id]/config.ts b/app/[lng]/evcp/(evcp)/approval/template/[id]/config.ts new file mode 100644 index 00000000..1ad5b2e2 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/approval/template/[id]/config.ts @@ -0,0 +1,33 @@ +// 결재 템플릿에서 사용할 변수(placeholder) 목록 +// DB를 거치지 않고 정적 파일에 정의하여 테스트/배포 리스크를 최소화합니다. +// ApprovalTemplateEditor 컴포넌트는 `variableName` 값을 사용해 {{변수명}} 형식으로 본문에 삽입합니다. + +export const variables = [ + { + variableName: "수신자 배열", // 예: ["홍길동", "김철수", ...] + variableType: "array", + description: "결재 수신자 목록 (이름/사번 등)", + }, + { + variableName: "송신자 배열", // 예: 상신자 정보 배열 + variableType: "array", + description: "결재 송신자(상신자) 목록", + }, + { + variableName: "견적 RFQ 요약", // 예: RFQ 견적 요약 HTML 테이블 + variableType: "html", + description: "견적 RFQ 요약표", + }, + { + variableName: "RFQ(PR) 요약", // 예: 구매요청(PR) 요약 HTML 테이블 + variableType: "html", + description: "RFQ(PR) 요약표", + }, + { + variableName: "첨부문서 리스트 요약", // 예: 첨부 파일 리스트 HTML + variableType: "html", + description: "첨부문서 리스트 요약", + }, +] as const; + + diff --git a/app/[lng]/evcp/(evcp)/approval/template/[id]/page.tsx b/app/[lng]/evcp/(evcp)/approval/template/[id]/page.tsx new file mode 100644 index 00000000..136b09eb --- /dev/null +++ b/app/[lng]/evcp/(evcp)/approval/template/[id]/page.tsx @@ -0,0 +1,58 @@ +import * as React from "react" +import { type Metadata } from "next" +import { notFound } from "next/navigation" + +import { getApprovalTemplate } from "@/lib/approval-template/service" +import { getApprovalLineOptions, getApprovalLineCategories } from "@/lib/approval-line/service" +import { ApprovalTemplateEditor } from "@/lib/approval-template/editor/approval-template-editor" +import { variables as configVariables } from "./config" + +interface ApprovalTemplateDetailPageProps { + params: Promise<{ + id: string + }> +} + +export async function generateMetadata({ params }: ApprovalTemplateDetailPageProps): Promise<Metadata> { + const { id } = await params + const template = await getApprovalTemplate(id) + + if (!template) { + return { + title: "템플릿을 찾을 수 없음", + } + } + + return { + title: `${template.name} - 템플릿 편집`, + description: template.description || `${template.name} 템플릿을 편집합니다.`, + } +} + +export default async function ApprovalTemplateDetailPage({ params }: ApprovalTemplateDetailPageProps) { + const { id } = await params + const [template, approvalLineOptions, approvalLineCategories] = await Promise.all([ + getApprovalTemplate(id), + getApprovalLineOptions(), + getApprovalLineCategories(), + ]) + + if (!template) { + notFound() + } + + return ( + <div className="flex flex-1 flex-col"> + {template && ( + <ApprovalTemplateEditor + templateId={id} + initialTemplate={template} + staticVariables={configVariables as unknown as Array<{ variableName: string }>} + approvalLineOptions={approvalLineOptions} + approvalLineCategories={approvalLineCategories} + /> + )} + </div> + ) +} + diff --git a/app/[lng]/evcp/(evcp)/approval/template/page.tsx b/app/[lng]/evcp/(evcp)/approval/template/page.tsx new file mode 100644 index 00000000..f475099c --- /dev/null +++ b/app/[lng]/evcp/(evcp)/approval/template/page.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { type Metadata } from 'next'; +import { Shell } from '@/components/shell'; +import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton'; +import { type SearchParams } from '@/types/table'; +import { getValidFilters } from '@/lib/data-table'; + +import { getApprovalTemplateList } from '@/lib/approval-template/service'; +import { SearchParamsApprovalTemplateCache } from '@/lib/approval-template/validations'; +import { ApprovalTemplateTable } from '@/lib/approval-template/table/approval-template-table'; + +export const metadata: Metadata = { + title: '결재 템플릿 관리', + description: '결재용 템플릿을 관리합니다.', +}; + +interface PageProps { + searchParams: SearchParams; +} + +export default async function ApprovalTemplatePage({ searchParams }: PageProps) { + const search = SearchParamsApprovalTemplateCache.parse(searchParams); + // getValidFilters 반환값이 undefined 인 경우 폴백 + const validFilters = getValidFilters(search.filters) ?? []; + + const promises = Promise.all([ + getApprovalTemplateList({ + ...search, + filters: validFilters, + }), + ]); + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight">결재 템플릿 관리</h2> + </div> + </div> + </div> + </div> + + {/* 테이블 */} + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={[ + '10rem', + '40rem', + '12rem', + '12rem', + '8rem', + '8rem', + ]} + shrinkZero + /> + } + > + <ApprovalTemplateTable promises={promises} /> + </React.Suspense> + </Shell> + ); +}
\ No newline at end of file diff --git a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts index fdf0c8d4..4ae1bbda 100644 --- a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts +++ b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_PR_INFORMATION/route.ts @@ -11,8 +11,6 @@ import { extractRequestData, convertXMLToDBData, processNestedArray, - createErrorResponse, - createSuccessResponse, createSoapResponse, withSoapLogging, } from '@/lib/soap/utils'; @@ -20,6 +18,9 @@ import { bulkUpsert, bulkReplaceSubTableData } from "@/lib/soap/batch-utils"; +import { + mapAndSaveECCRfqData +} from "@/lib/soap/ecc-mapper"; // 스키마에서 타입 추론 @@ -90,10 +91,43 @@ export async function POST(request: NextRequest) { } } - // 5) 데이터베이스 저장 + // 5) 원본 ECC 데이터 저장 (기존 로직 유지) await saveToDatabase(processedData); - console.log(`🎉 처리 완료: ${processedData.length}개 PR 데이터`); + // 6) ZBSART에 따라 비즈니스 테이블 분기 처리 + const anHeaders: BidHeaderData[] = []; + const abHeaders: BidHeaderData[] = []; + const anItems: BidItemData[] = []; + const abItems: BidItemData[] = []; + + // ZBSART에 따라 데이터 분류 + for (const prData of processedData) { + if (prData.bidHeader.ZBSART === 'AN') { + anHeaders.push(prData.bidHeader); + anItems.push(...prData.bidItems); + } else if (prData.bidHeader.ZBSART === 'AB') { + abHeaders.push(prData.bidHeader); + abItems.push(...prData.bidItems); + } + } + + // AN (RFQ) 데이터 처리 - procurementRfqs 테이블 + let rfqMappingResult = null; + if (anHeaders.length > 0) { + rfqMappingResult = await mapAndSaveECCRfqData(anHeaders, anItems); + if (!rfqMappingResult.success) { + throw new Error(`RFQ 비즈니스 테이블 매핑 실패: ${rfqMappingResult.message}`); + } + } + + // AB (Bidding) 데이터 처리 - TODO + if (abHeaders.length > 0) { + console.log(`⚠️ TODO: Bidding 데이터 처리 필요 - ${abHeaders.length}개 헤더, ${abItems.length}개 아이템`); + // TODO: mapAndSaveECCBiddingData 함수 구현 필요 + // const biddingMappingResult = await mapAndSaveECCBiddingData(abHeaders, abItems); + } + + console.log(`🎉 처리 완료: ${processedData.length}개 PR 데이터, ${rfqMappingResult?.processedCount || 0}개 RFQ 매핑, ${abHeaders.length}개 Bidding (TODO)`); // 6) 성공 응답 반환 return createSoapResponse('http://60.101.108.100/', { diff --git a/components/common/organization/organization-manager-selector.tsx b/components/common/organization/organization-manager-selector.tsx new file mode 100644 index 00000000..c715b3a1 --- /dev/null +++ b/components/common/organization/organization-manager-selector.tsx @@ -0,0 +1,338 @@ +"use client" + +import * as React from "react" +import { Search, X, Building2, ChevronLeft, ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { useDebounce } from "@/hooks/use-debounce" +import { cn } from "@/lib/utils" +import { Skeleton } from "@/components/ui/skeleton" +import { searchOrganizationsForManager } from "@/lib/knox-api/organization-service" + +// 조직 관리자 타입 정의 +export interface OrganizationManagerItem { + id: string + departmentCode: string + departmentName: string + managerId: string + managerName: string + managerTitle: string + companyCode: string + companyName: string +} + +// 페이지네이션 정보 타입 +interface PaginationInfo { + page: number + perPage: number + total: number + pageCount: number + hasNextPage: boolean + hasPrevPage: boolean +} + +export interface OrganizationManagerSelectorProps { + /** 선택된 조직 관리자들 */ + selectedManagers?: OrganizationManagerItem[] + /** 조직 관리자 선택 변경 콜백 */ + onManagersChange?: (managers: OrganizationManagerItem[]) => void + /** 단일 선택 모드 여부 */ + singleSelect?: boolean + /** placeholder 텍스트 */ + placeholder?: string + /** 입력 없이 focus 시 표시할 placeholder */ + noValuePlaceHolder?: string + /** 비활성화 여부 */ + disabled?: boolean + /** 최대 선택 가능 조직 관리자 수 */ + maxSelections?: number + /** 컴포넌트 클래스명 */ + className?: string + /** 선택 후 팝오버 닫기 여부 */ + closeOnSelect?: boolean +} + +export function OrganizationManagerSelector({ + selectedManagers = [], + onManagersChange, + singleSelect = false, + placeholder = "조직 관리자를 검색하세요...", + noValuePlaceHolder = "조직명 또는 관리자명으로 검색하세요", + disabled = false, + maxSelections, + className, + closeOnSelect = true +}: OrganizationManagerSelectorProps) { + const [searchQuery, setSearchQuery] = React.useState("") + const [isSearching, setIsSearching] = React.useState(false) + const [searchResults, setSearchResults] = React.useState<OrganizationManagerItem[]>([]) + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false) + const [currentPage, setCurrentPage] = React.useState(1) + const [pagination, setPagination] = React.useState<PaginationInfo>({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + const [searchError, setSearchError] = React.useState<string | null>(null) + + const inputRef = React.useRef<HTMLInputElement>(null) + + // Debounce 적용된 검색어 + const debouncedSearchQuery = useDebounce(searchQuery, 300) + + // 검색 실행 + const performSearch = React.useCallback(async (query: string, page: number = 1) => { + if (!query.trim()) { + setSearchResults([]) + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }) + return + } + + setIsSearching(true) + setSearchError(null) + + try { + const result = await searchOrganizationsForManager({ + search: query, + page, + perPage: 10, + }) + + setSearchResults(result.data) + setPagination({ + page: result.pageCount, + perPage: 10, + total: result.total, + pageCount: result.pageCount, + hasNextPage: page < result.pageCount, + hasPrevPage: page > 1, + }) + } catch (error) { + setSearchError("검색 중 오류가 발생했습니다.") + setSearchResults([]) + } finally { + setIsSearching(false) + } + }, []) + + // 검색어 변경 시 검색 실행 + React.useEffect(() => { + performSearch(debouncedSearchQuery, 1) + setCurrentPage(1) + }, [debouncedSearchQuery, performSearch]) + + // 페이지 변경 시 검색 실행 + const handlePageChange = React.useCallback((newPage: number) => { + setCurrentPage(newPage) + performSearch(debouncedSearchQuery, newPage) + }, [debouncedSearchQuery, performSearch]) + + // 선택된 관리자 제거 + const removeManager = React.useCallback((managerId: string) => { + const updated = selectedManagers.filter(m => m.id !== managerId) + onManagersChange?.(updated) + }, [selectedManagers, onManagersChange]) + + // 관리자 선택 + const selectManager = React.useCallback((manager: OrganizationManagerItem) => { + if (singleSelect) { + onManagersChange?.([manager]) + if (closeOnSelect) { + setIsPopoverOpen(false) + } + return + } + + // 최대 선택 수 체크 + if (maxSelections && selectedManagers.length >= maxSelections) { + return + } + + // 이미 선택된 관리자인지 확인 + const isAlreadySelected = selectedManagers.some(m => m.id === manager.id) + if (isAlreadySelected) { + return + } + + const updated = [...selectedManagers, manager] + onManagersChange?.(updated) + + if (closeOnSelect) { + setIsPopoverOpen(false) + } + }, [selectedManagers, onManagersChange, singleSelect, maxSelections, closeOnSelect]) + + // 전체 선택 해제 + const clearAll = React.useCallback(() => { + onManagersChange?.([]) + }, [onManagersChange]) + + return ( + <div className={cn("w-full", className)}> + {/* 선택된 관리자들 표시 */} + {selectedManagers.length > 0 && ( + <div className="mb-3 flex flex-wrap gap-2"> + {selectedManagers.map((manager) => ( + <Badge + key={manager.id} + variant="secondary" + className="flex items-center gap-1" + > + <Building2 className="w-3 h-3" /> + <span className="text-xs"> + {manager.departmentName} - {manager.managerName} + </span> + <Button + variant="ghost" + size="sm" + className="h-auto p-0 ml-1 hover:bg-transparent" + onClick={() => removeManager(manager.id)} + > + <X className="w-3 h-3" /> + </Button> + </Badge> + ))} + {selectedManagers.length > 1 && ( + <Button + variant="ghost" + size="sm" + onClick={clearAll} + className="h-6 px-2 text-xs" + > + 전체 해제 + </Button> + )} + </div> + )} + + {/* 검색 입력 */} + <div className="relative"> + <Input + ref={inputRef} + placeholder={selectedManagers.length === 0 ? placeholder : noValuePlaceHolder} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onFocus={() => setIsPopoverOpen(true)} + disabled={disabled} + className="w-full" + /> + + {/* 검색 결과 팝오버 */} + {isPopoverOpen && ( + <div className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-80 overflow-hidden"> + {/* 검색 중 표시 */} + {isSearching && ( + <div className="p-4 space-y-2"> + {Array.from({ length: 3 }).map((_, i) => ( + <div key={i} className="flex items-center space-x-3"> + <Skeleton className="h-4 w-4" /> + <div className="space-y-2 flex-1"> + <Skeleton className="h-4 w-3/4" /> + <Skeleton className="h-3 w-1/2" /> + </div> + </div> + ))} + </div> + )} + + {/* 검색 결과 */} + {!isSearching && searchResults.length > 0 && ( + <div className="max-h-60 overflow-y-auto"> + {searchResults.map((manager) => { + const isSelected = selectedManagers.some(m => m.id === manager.id) + return ( + <div + key={manager.id} + className={cn( + "p-3 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0", + isSelected && "bg-blue-50" + )} + onClick={() => selectManager(manager)} + > + <div className="flex items-center justify-between"> + <div className="flex-1"> + <div className="font-medium text-sm"> + {manager.departmentName} + </div> + <div className="text-xs text-gray-500"> + {manager.managerName} ({manager.managerTitle}) + </div> + <div className="text-xs text-gray-400"> + {manager.companyName} + </div> + </div> + {isSelected && ( + <Badge variant="secondary" className="text-xs"> + 선택됨 + </Badge> + )} + </div> + </div> + ) + })} + </div> + )} + + {/* 검색 결과 없음 */} + {!isSearching && searchQuery && searchResults.length === 0 && !searchError && ( + <div className="p-4 text-center text-gray-500"> + 검색 결과가 없습니다. + </div> + )} + + {/* 오류 메시지 */} + {searchError && ( + <div className="p-4 text-center text-red-500"> + {searchError} + </div> + )} + + {/* 페이지네이션 */} + {!isSearching && searchResults.length > 0 && ( + <div className="flex items-center justify-between p-3 border-t border-gray-200"> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage - 1)} + disabled={!pagination.hasPrevPage} + > + <ChevronLeft className="w-4 h-4" /> + </Button> + <span className="text-sm text-gray-500"> + {currentPage} / {pagination.pageCount} + </span> + <Button + variant="ghost" + size="sm" + onClick={() => handlePageChange(currentPage + 1)} + disabled={!pagination.hasNextPage} + > + <ChevronRight className="w-4 h-4" /> + </Button> + </div> + )} + </div> + )} + </div> + + {/* 팝오버 외부 클릭 시 닫기 */} + {isPopoverOpen && ( + <div + className="fixed inset-0 z-40" + onClick={() => setIsPopoverOpen(false)} + /> + )} + </div> + ) +}
\ No newline at end of file diff --git a/components/knox/approval/ApprovalLineSelector.tsx b/components/knox/approval/ApprovalLineSelector.tsx new file mode 100644 index 00000000..bbe6bd7f --- /dev/null +++ b/components/knox/approval/ApprovalLineSelector.tsx @@ -0,0 +1,444 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { toast } from "sonner"; +import { Trash2, GripVertical } from "lucide-react"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + restrictToVerticalAxis, + restrictToParentElement, +} from "@dnd-kit/modifiers"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + UserSelector, + type UserSelectItem, +} from "@/components/common/user/user-selector"; + +// ----- Types ----- +export type ApprovalRole = "0" | "1" | "2" | "3" | "4" | "7" | "9"; + +export interface ApprovalLineItem { + id: string; + epId?: string; + userId?: string; + emailAddress?: string; + name?: string; + deptName?: string; + role: ApprovalRole; + seq: string; // 0-based; 동일 seq는 병렬 그룹 의미 + opinion?: string; +} + +export interface ApprovalLineSelectorProps { + value: ApprovalLineItem[]; + onChange: (next: ApprovalLineItem[]) => void; + placeholder?: string; + maxSelections?: number; + domainFilter?: any; // 프로젝트 전역 타입에 맞춰 느슨히 둠 + className?: string; +} + +// 역할 텍스트 매핑 +const getRoleText = (role: string) => { + const map: Record<string, string> = { + "0": "기안", + "1": "결재", + "2": "합의", + "3": "후결", + "4": "병렬합의", + "7": "병렬결재", + "9": "통보", + }; + return map[role] || role; +}; + +// 고유 ID 생성 +const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + +// Sortable Group Card (seq 단위) +interface SortableApprovalGroupProps { + group: ApprovalLineItem[]; + index: number; + onRemoveGroup: () => void; + canRemove: boolean; + selected: boolean; + onSelect: () => void; + onChangeRole: (role: ApprovalRole) => void; +} + +function SortableApprovalGroup({ + group, + index, + onRemoveGroup, + canRemove, + selected, + onSelect, + onChangeRole, +}: SortableApprovalGroupProps) { + const seq = group[0].seq; + const role = group[0].role; + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: group[0].id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } as React.CSSProperties; + + const isParallel = role === "7" || role === "4"; + + return ( + <div + ref={setNodeRef} + style={style} + className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`} + > + {/* Drag handle */} + <div + {...attributes} + {...listeners} + className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" + > + <GripVertical className="w-5 h-5" /> + </div> + + {/* Group select (skip for drafter) */} + {index !== 0 ? ( + <Checkbox checked={selected} onCheckedChange={onSelect} onClick={(e) => e.stopPropagation()} /> + ) : ( + <div className="w-4 h-4" /> + )} + + {/* seq index */} + <Badge variant="outline">{parseInt(seq) + 1}</Badge> + + {/* Group details */} + <div className="flex-1 grid grid-cols-3 gap-3"> + {/* Users in group */} + <div className="flex flex-col justify-center gap-1"> + {group.map((u) => ( + <div key={u.id} className="text-sm"> + {u.name || "Knox 이름 없음"} + {u.deptName ? ` / ${u.deptName}` : ""} + </div> + ))} + </div> + + {/* Role UI */} + <div className="flex items-center"> + {seq === "0" ? ( + <Badge variant="secondary" className="w-full justify-center">기안</Badge> + ) : isParallel ? ( + <Badge variant="secondary" className="w-full justify-center">{getRoleText(role)}</Badge> + ) : ( + <ToggleGroup + type="single" + value={role} + onValueChange={(val) => val && onChangeRole(val as ApprovalRole)} + > + <ToggleGroupItem value="1">결재</ToggleGroupItem> + <ToggleGroupItem value="2">합의</ToggleGroupItem> + <ToggleGroupItem value="9">통보</ToggleGroupItem> + </ToggleGroup> + )} + </div> + + {/* Delete */} + <div className="flex items-center justify-end"> + {canRemove && ( + <Button type="button" variant="ghost" size="sm" onClick={onRemoveGroup}> + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </div> + </div> + ); +} + +export function ApprovalLineSelector({ + value, + onChange, + placeholder = "결재자를 검색하세요...", + maxSelections = 10, + domainFilter, + className, +}: ApprovalLineSelectorProps) { + const aplns = value; + const [selectedSeqs, setSelectedSeqs] = React.useState<string[]>([]); + + const toggleSelectGroup = (seq: string) => { + setSelectedSeqs((prev) => (prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq])); + }; + const clearSelection = () => setSelectedSeqs([]); + + // drag sensors + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // Utilities + const reorderBySeq = (list: ApprovalLineItem[]): ApprovalLineItem[] => { + const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq)); + const seqMap = new Map<string, string>(); + let nextSeq = 0; + return sorted.map((apln) => { + if (!seqMap.has(apln.seq)) { + seqMap.set(apln.seq, nextSeq.toString()); + nextSeq += 1; + } + return { ...apln, seq: seqMap.get(apln.seq)! }; + }); + }; + + const addApprovalUsers = (users: UserSelectItem[]) => { + const newAplns = [...aplns]; + users.forEach((user) => { + const exists = newAplns.findIndex((apln) => apln.userId === user.id.toString()); + if (exists === -1) { + // 현재 존재하는 seq 중 최댓값 + 1을 다음 seq로 사용 (0은 상신자) + const uniqueSeqs = Array.from(new Set(newAplns.map((a) => parseInt(a.seq)))); + const maxSeq = uniqueSeqs.length ? Math.max(...uniqueSeqs) : -1; + const newSeq = (Math.max(1, maxSeq + 1)).toString(); + newAplns.push({ + id: generateUniqueId(), + epId: (user as any).epId, + userId: user.id.toString(), + emailAddress: user.email, + name: user.name, + deptName: (user as any).deptName ?? undefined, + role: "1", + seq: newSeq, + opinion: "", + }); + } + }); + onChange(newAplns); + }; + + const removeApprovalGroup = (seq: string) => { + if (seq === "0") return; // 상신자 삭제 불가 + const remaining = aplns.filter((a) => a.seq !== seq); + onChange(reorderBySeq(remaining)); + }; + + const applyParallel = () => { + if (selectedSeqs.length < 2) { + toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다."); + return; + } + const current = aplns; + const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq)); + const roles = Array.from(new Set(selectedAplns.map((a) => a.role))); + if (roles.length !== 1) { + toast.error("선택된 항목의 역할이 동일해야 합니다."); + return; + } + const role = roles[0]; + let newRole: ApprovalRole; + if (role === "1") newRole = "7"; + else if (role === "2") newRole = "4"; + else if (role === "9") newRole = "9"; + else { + toast.error("결재, 합의 또는 통보만 병렬 지정 가능합니다."); + return; + } + const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq))); + const updated = current.map((a) => + selectedSeqs.includes(a.seq) ? { ...a, role: newRole, seq: minSeq.toString() } : a, + ); + onChange(reorderBySeq(updated)); + clearSelection(); + }; + + const applyAfter = () => { + if (selectedSeqs.length !== 1) { + toast.error("후결은 한 명만 지정할 수 있습니다."); + return; + } + const targetSeq = selectedSeqs[0]; + const targetRole = aplns.find((a) => a.seq === targetSeq)?.role; + if (targetRole === "7" || targetRole === "4") { + toast.error("병렬 그룹은 후결로 전환할 수 없습니다."); + return; + } + const updated = aplns.map((a) => + a.seq === targetSeq ? { ...a, role: (a.role === "3" ? "1" : "3") as ApprovalRole } : a, + ); + onChange(reorderBySeq(updated)); + clearSelection(); + }; + + const ungroupParallel = () => { + if (selectedSeqs.length === 0) { + toast.error("해제할 결재선을 선택하세요."); + return; + } + let newSeqCounter = 1; // 0은 상신자 유지 + const updated = aplns.map((a) => { + if (selectedSeqs.includes(a.seq)) { + let newRole: ApprovalRole = a.role; + if (a.role === "7") newRole = "1"; + if (a.role === "4") newRole = "2"; + return { ...a, role: newRole, seq: "" }; + } + return { ...a }; + }); + const reassigned = updated + .sort((x, y) => parseInt(x.seq || "0") - parseInt(y.seq || "0")) + .map((a) => { + if (a.seq === "0") return a; + const next = { ...a, seq: newSeqCounter.toString() }; + newSeqCounter += 1; + return next; + }); + onChange(reassigned); + clearSelection(); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const activeKey = active.id as string; + const overKey = over.id as string; + + const idToSeq = new Map<string, string>(); + aplns.forEach((a) => idToSeq.set(a.id, a.seq)); + const activeSeq = idToSeq.get(activeKey); + const overSeq = idToSeq.get(overKey); + if (!activeSeq || !overSeq) return; + if (activeSeq === "0" || overSeq === "0") return; // 상신자 이동 불가 + + const seqOrder = Array.from(new Set(aplns.map((a) => a.seq))); + const keyOrder = seqOrder.map((seq) => aplns.find((a) => a.seq === seq)!.id); + const oldIndex = keyOrder.indexOf(activeKey); + const newIndex = keyOrder.indexOf(overKey); + const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex); + + const keyToNewSeq = new Map<string, string>(); + newKeyOrder.forEach((k, idx) => keyToNewSeq.set(k, idx.toString())); + + const updatedAplns: ApprovalLineItem[] = []; + newKeyOrder.forEach((k) => { + const oldSeq = idToSeq.get(k)!; + const groupItems = aplns.filter((a) => a.seq === oldSeq); + groupItems.forEach((item) => { + updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! }); + }); + }); + onChange(updatedAplns); + }; + + // Render groups by seq + const groups = React.useMemo(() => { + const grouped = Object.values( + aplns.reduce<Record<string, ApprovalLineItem[]>>((acc, apln) => { + acc[apln.seq] = acc[apln.seq] ? [...acc[apln.seq], apln] : [apln]; + return acc; + }, {}), + ).sort((a, b) => parseInt(a[0].seq) - parseInt(b[0].seq)); + return grouped; + }, [aplns]); + + return ( + <div className={className}> + <h3 className="text-lg font-semibold">결재 경로</h3> + + {/* Controls */} + <div className="flex justify-end gap-2 mb-2"> + <Button type="button" variant="outline" size="sm" onClick={applyParallel}> + 병렬 + </Button> + <Button type="button" variant="outline" size="sm" onClick={applyAfter}> + 후결 + </Button> + <Button type="button" variant="outline" size="sm" onClick={ungroupParallel}> + 해제 + </Button> + </div> + + {/* User add */} + <div className="p-4 border border-dashed border-gray-300 rounded-lg"> + <div className="mb-2"> + <label className="text-sm font-medium text-gray-700">결재자 추가</label> + <p className="text-xs text-gray-500">사용자를 검색하여 결재 라인에 추가하세요</p> + </div> + <UserSelector + selectedUsers={[]} + onUsersChange={addApprovalUsers} + placeholder={placeholder} + domainFilter={domainFilter} + maxSelections={maxSelections} + /> + </div> + + {/* Groups */} + <div className="mt-4"> + {aplns.length > 0 ? ( + <DndContext + sensors={sensors} + collisionDetection={closestCenter} + modifiers={[restrictToVerticalAxis, restrictToParentElement]} + onDragEnd={handleDragEnd} + > + <SortableContext items={groups.map((g) => g[0].id)} strategy={verticalListSortingStrategy}> + <div className="space-y-3"> + {groups.map((group, idx) => ( + <SortableApprovalGroup + key={group[0].id} + group={group} + index={idx} + onRemoveGroup={() => removeApprovalGroup(group[0].seq)} + canRemove={idx !== 0 && aplns.length > 1} + selected={selectedSeqs.includes(group[0].seq)} + onSelect={() => toggleSelectGroup(group[0].seq)} + onChangeRole={(role) => { + // 단일 그룹일 때만 역할 변경 허용 + if (group.length > 1) return; + const gid = group[0].id; + const updated = aplns.map((a) => (a.id === gid ? { ...a, role } : a)); + onChange(updated); + }} + /> + ))} + </div> + </SortableContext> + </DndContext> + ) : ( + <div className="text-center py-8 text-gray-500"> + <p>결재자를 추가해주세요</p> + </div> + )} + </div> + + <Separator className="my-4" /> + </div> + ); +} + +export default ApprovalLineSelector; + + diff --git a/components/knox/approval/ApprovalSubmit.tsx b/components/knox/approval/ApprovalSubmit.tsx index 8b58aba6..bfe66981 100644 --- a/components/knox/approval/ApprovalSubmit.tsx +++ b/components/knox/approval/ApprovalSubmit.tsx @@ -1,1092 +1,1294 @@ -'use client' - -import { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; - -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; -import { Checkbox } from '@/components/ui/checkbox'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -import { toast } from 'sonner'; -import { Loader2, Trash2, FileText, AlertCircle, GripVertical } from 'lucide-react'; -import { debugLog, debugError } from '@/lib/debug-utils' +"use client"; + +import { useState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { toast } from "sonner"; +import { + Loader2, + Trash2, + FileText, + AlertCircle, + GripVertical, +} from "lucide-react"; +import { debugLog, debugError } from "@/lib/debug-utils"; // dnd-kit imports for drag and drop import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, -} from '@dnd-kit/core'; + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; // 드래그 이동을 수직 축으로 제한하고, 리스트 영역 밖으로 벗어나지 않도록 하는 modifier import { - restrictToVerticalAxis, - restrictToParentElement, -} from '@dnd-kit/modifiers'; + restrictToVerticalAxis, + restrictToParentElement, +} from "@dnd-kit/modifiers"; import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - useSortable, -} from '@dnd-kit/sortable'; -import { - CSS, -} from '@dnd-kit/utilities'; + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; // API 함수 및 타입 -import { submitApproval, submitSecurityApproval, createSubmitApprovalRequest, createApprovalLine } from '@/lib/knox-api/approval/approval'; -import type { ApprovalLine, SubmitApprovalRequest } from '@/lib/knox-api/approval/approval'; +import { + submitApproval, + submitSecurityApproval, + createSubmitApprovalRequest, + createApprovalLine, +} from "@/lib/knox-api/approval/approval"; +import type { + ApprovalLine, + SubmitApprovalRequest, +} from "@/lib/knox-api/approval/approval"; // 역할 텍스트 매핑 (기존 mock util 대체) const getRoleText = (role: string) => { - const map: Record<string, string> = { - '0': '기안', - '1': '결재', - '2': '합의', - '3': '후결', - '4': '병렬합의', - '7': '병렬결재', - '9': '통보', - }; - return map[role] || role; + const map: Record<string, string> = { + "0": "기안", + "1": "결재", + "2": "합의", + "3": "후결", + "4": "병렬합의", + "7": "병렬결재", + "9": "통보", + }; + return map[role] || role; }; // TiptapEditor 컴포넌트 -import RichTextEditor from '@/components/rich-text-editor/RichTextEditor'; +import RichTextEditor from "@/components/rich-text-editor/RichTextEditor"; // UserSelector 컴포넌트 -import { UserSelector, type UserSelectItem } from '@/components/common/user/user-selector'; -import { useSession } from 'next-auth/react'; +import { + UserSelector, + type UserSelectItem, +} from "@/components/common/user/user-selector"; +import { useSession } from "next-auth/react"; // UserSelector에서 반환되는 사용자에 epId가 포함될 수 있으므로 확장 타입 정의 interface ExtendedUserSelectItem extends UserSelectItem { - epId?: string; + epId?: string; } // 역할 코드 타입 정의 -type ApprovalRole = '0' | '1' | '2' | '3' | '4' | '7' | '9'; +type ApprovalRole = "0" | "1" | "2" | "3" | "4" | "7" | "9"; // 결재 라인 아이템 타입 정의 (고유 ID 포함) interface ApprovalLineItem { - id: string; // 내부 고유 식별자 - epId?: string; // Knox 고유 ID (전사 고유) - userId?: string; // DB User PK - emailAddress?: string; - name?: string; // 사용자 이름 - deptName?: string; // 부서명 - role: ApprovalRole; - seq: string; - opinion?: string; + id: string; // 내부 고유 식별자 + epId?: string; // Knox 고유 ID (전사 고유) + userId?: string; // DB User PK + emailAddress?: string; + name?: string; // 사용자 이름 + deptName?: string; // 부서명 + role: ApprovalRole; + seq: string; + opinion?: string; } const formSchema = z.object({ - subject: z.string().min(1, '제목은 필수입니다'), - contents: z.string().min(1, '내용은 필수입니다'), - contentsType: z.literal('HTML'), - docSecuType: z.enum(['PERSONAL', 'CONFIDENTIAL', 'CONFIDENTIAL_STRICT']), - urgYn: z.boolean(), - importantYn: z.boolean(), - notifyOption: z.enum(['0', '1', '2', '3']), - docMngSaveCode: z.enum(['0', '1']), - sbmLang: z.enum(['ko', 'ja', 'zh', 'en']), - timeZone: z.string().default('GMT+9'), - aplns: z.array(z.object({ - id: z.string(), // 고유 식별자 - epId: z.string().optional(), - userId: z.string().optional(), - emailAddress: z.string().email('유효한 이메일 주소를 입력해주세요').optional(), - name: z.string().optional(), - deptName: z.string().optional(), - role: z.enum(['0', '1', '2', '3', '4', '7', '9']), - seq: z.string(), - opinion: z.string().optional() - })).min(1, '최소 1개의 결재 경로가 필요합니다'), - // 첨부파일 (선택) - attachments: z.any().optional() + subject: z.string().min(1, "제목은 필수입니다"), + contents: z.string().min(1, "내용은 필수입니다"), + contentsType: z.literal("HTML"), + docSecuType: z.enum(["PERSONAL", "CONFIDENTIAL", "CONFIDENTIAL_STRICT"]), + urgYn: z.boolean(), + importantYn: z.boolean(), + notifyOption: z.enum(["0", "1", "2", "3"]), + docMngSaveCode: z.enum(["0", "1"]), + sbmLang: z.enum(["ko", "ja", "zh", "en"]), + timeZone: z.string().default("GMT+9"), + aplns: z + .array( + z.object({ + id: z.string(), // 고유 식별자 + epId: z.string().optional(), + userId: z.string().optional(), + emailAddress: z + .string() + .email("유효한 이메일 주소를 입력해주세요") + .optional(), + name: z.string().optional(), + deptName: z.string().optional(), + role: z.enum(["0", "1", "2", "3", "4", "7", "9"]), + seq: z.string(), + opinion: z.string().optional(), + }), + ) + .min(1, "최소 1개의 결재 경로가 필요합니다"), + // 첨부파일 (선택) + attachments: z.any().optional(), }); type FormData = z.infer<typeof formSchema>; interface ApprovalSubmitProps { - onSubmitSuccess?: (apInfId: string) => void; + onSubmitSuccess?: (apInfId: string) => void; } // Sortable한 결재 라인 컴포넌트 interface SortableApprovalLineProps { - apln: ApprovalLineItem; - index: number; - form: ReturnType<typeof useForm<FormData>>; - onRemove: () => void; - canRemove: boolean; - selected: boolean; - onSelect: () => void; + apln: ApprovalLineItem; + index: number; + form: ReturnType<typeof useForm<FormData>>; + onRemove: () => void; + canRemove: boolean; + selected: boolean; + onSelect: () => void; } // eslint-disable-next-line @typescript-eslint/no-unused-vars -function SortableApprovalLine({ apln, index, form, onRemove, canRemove, selected, onSelect }: SortableApprovalLineProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: apln.id }); // 고유 ID 사용 - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, // 드래그 중일 때 투명도 조절 - }; - - - return ( - <div - ref={setNodeRef} - style={style} - className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? 'ring-2 ring-primary' : ''}`} - > - {/* 드래그 핸들 */} - <div - {...attributes} - {...listeners} - className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" - > - <GripVertical className="w-5 h-5" /> - </div> - - {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */} - {index !== 0 ? ( - <Checkbox - checked={selected} - onCheckedChange={() => onSelect()} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - <div className="w-4 h-4" /> // 기안자용 빈 공간 (체크박스가 없으므로) - )} - - {/* 실제 seq 기준 표시 */} - <Badge variant="outline">{parseInt(apln.seq) + 1}</Badge> - - <div className="flex-1 grid grid-cols-4 gap-3"> - {/* 사용자 정보 표시 */} - <div className="flex items-center space-x-2"> - <div> - <div className="font-medium text-sm"> - {(apln.name || 'Knox 이름 없음')}{apln.deptName ? ` / ${apln.deptName}` : ''} +function SortableApprovalLine({ + apln, + index, + form, + onRemove, + canRemove, + selected, + onSelect, +}: SortableApprovalLineProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: apln.id }); // 고유 ID 사용 + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, // 드래그 중일 때 투명도 조절 + }; + + return ( + <div + ref={setNodeRef} + style={style} + className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`} + > + {/* 드래그 핸들 */} + <div + {...attributes} + {...listeners} + className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" + > + <GripVertical className="w-5 h-5" /> </div> - </div> - <FormField - control={form.control} - name={`aplns.${index}.id`} - render={() => ( - <FormItem className="hidden"> - <FormControl> - <Input type="hidden" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name={`aplns.${index}.epId`} - render={() => ( - <FormItem className="hidden"> - <FormControl> - <Input type="hidden" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name={`aplns.${index}.userId`} - render={() => ( - <FormItem className="hidden"> - <FormControl> - <Input type="hidden" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name={`aplns.${index}.emailAddress`} - render={() => ( - <FormItem className="hidden"> - <FormControl> - <Input type="hidden" /> - </FormControl> - <FormMessage /> - </FormItem> + + {/* 선택 체크박스 (상신자는 제외하지만 공간은 확보) */} + {index !== 0 ? ( + <Checkbox + checked={selected} + onCheckedChange={() => onSelect()} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <div className="w-4 h-4" /> // 기안자용 빈 공간 (체크박스가 없으므로) )} - /> - </div> - {/* 역할 선택 */} - {index === 0 ? ( - // 상신자는 역할 선택 대신 고정 표시 - <div className="flex items-center"> - <Badge variant="secondary">기안</Badge> - </div> - ) : ( - <FormField - control={form.control} - name={`aplns.${index}.role`} - render={({ field }) => { - // 병렬 여부 판단 - const isParallel = field.value === '4' || field.value === '7'; - - // 병렬, 후결 값을 제외한 기본 역할 - const baseRole: ApprovalRole = field.value === '7' ? '1' : field.value === '4' ? '2' : field.value === '3' ? '1' : field.value as ApprovalRole; - - // 기본 역할 변경 핸들러 - const handleBaseRoleChange = (val: string) => { - if (!val) return; - let newRole = val; - if (isParallel) { - if (val === '1') newRole = '7'; - else if (val === '2') newRole = '4'; - } - field.onChange(newRole); - }; - - // 병렬인 경우 한 개 버튼으로 표시 - if (isParallel) { - return ( - <FormItem className="w-full"> - <Badge className="w-full justify-center" variant="secondary"> - {getRoleText(field.value)} - </Badge> - </FormItem> - ); - } - - return ( - <FormItem> - <div className="flex flex-col gap-2"> - <ToggleGroup - type="single" - value={baseRole} - onValueChange={handleBaseRoleChange} - > - <ToggleGroupItem value="1">결재</ToggleGroupItem> - <ToggleGroupItem value="2">합의</ToggleGroupItem> - <ToggleGroupItem value="9">통보</ToggleGroupItem> - </ToggleGroup> - </div> - <FormMessage /> - </FormItem> - ); - }} - /> - )} - - {/* 의견 입력란 제거됨 */} - - {/* 역할 표시 */} - <div className="flex items-center justify-between"> - <Badge variant="secondary"> - {getRoleText(apln.role)} - </Badge> - - {canRemove && ( - <Button - type="button" - variant="ghost" - size="sm" - onClick={onRemove} - > - <Trash2 className="w-4 h-4" /> - </Button> - )} + {/* 실제 seq 기준 표시 */} + <Badge variant="outline">{parseInt(apln.seq) + 1}</Badge> + + <div className="flex-1 grid grid-cols-4 gap-3"> + {/* 사용자 정보 표시 */} + <div className="flex items-center space-x-2"> + <div> + <div className="font-medium text-sm"> + {apln.name || "Knox 이름 없음"} + {apln.deptName ? ` / ${apln.deptName}` : ""} + </div> + </div> + <FormField + control={form.control} + name={`aplns.${index}.id`} + render={() => ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`aplns.${index}.epId`} + render={() => ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`aplns.${index}.userId`} + render={() => ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`aplns.${index}.emailAddress`} + render={() => ( + <FormItem className="hidden"> + <FormControl> + <Input type="hidden" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 역할 선택 */} + {index === 0 ? ( + // 상신자는 역할 선택 대신 고정 표시 + <div className="flex items-center"> + <Badge variant="secondary">기안</Badge> + </div> + ) : ( + <FormField + control={form.control} + name={`aplns.${index}.role`} + render={({ field }) => { + // 병렬 여부 판단 + const isParallel = + field.value === "4" || field.value === "7"; + + // 병렬, 후결 값을 제외한 기본 역할 + const baseRole: ApprovalRole = + field.value === "7" + ? "1" + : field.value === "4" + ? "2" + : field.value === "3" + ? "1" + : (field.value as ApprovalRole); + + // 기본 역할 변경 핸들러 + const handleBaseRoleChange = (val: string) => { + if (!val) return; + let newRole = val; + if (isParallel) { + if (val === "1") newRole = "7"; + else if (val === "2") newRole = "4"; + } + field.onChange(newRole); + }; + + // 병렬인 경우 한 개 버튼으로 표시 + if (isParallel) { + return ( + <FormItem className="w-full"> + <Badge + className="w-full justify-center" + variant="secondary" + > + {getRoleText(field.value)} + </Badge> + </FormItem> + ); + } + + return ( + <FormItem> + <div className="flex flex-col gap-2"> + <ToggleGroup + type="single" + value={baseRole} + onValueChange={handleBaseRoleChange} + > + <ToggleGroupItem value="1"> + 결재 + </ToggleGroupItem> + <ToggleGroupItem value="2"> + 합의 + </ToggleGroupItem> + <ToggleGroupItem value="9"> + 통보 + </ToggleGroupItem> + </ToggleGroup> + </div> + <FormMessage /> + </FormItem> + ); + }} + /> + )} + + {/* 의견 입력란 제거됨 */} + + {/* 역할 표시 */} + <div className="flex items-center justify-between"> + <Badge variant="secondary">{getRoleText(apln.role)}</Badge> + + {canRemove && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={onRemove} + > + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </div> </div> - </div> - </div> - ); + ); } // Sortable Approval Group (seq 단위 카드) interface SortableApprovalGroupProps { - group: ApprovalLineItem[]; // 동일 seq 항목들 - index: number; - form: ReturnType<typeof useForm<FormData>>; - onRemoveGroup: () => void; - canRemove: boolean; - selected: boolean; - onSelect: () => void; + group: ApprovalLineItem[]; // 동일 seq 항목들 + index: number; + form: ReturnType<typeof useForm<FormData>>; + onRemoveGroup: () => void; + canRemove: boolean; + selected: boolean; + onSelect: () => void; } -function SortableApprovalGroup({ group, index, form, onRemoveGroup, canRemove, selected, onSelect }: SortableApprovalGroupProps) { - const seq = group[0].seq; - const role = group[0].role; - // 그룹을 식별할 안정적인 고유 키(첫 구성원의 id 활용) - const groupKey = group[0].id; - - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: groupKey }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( - <div - ref={setNodeRef} - style={style} - className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? 'ring-2 ring-primary' : ''}`} - > - {/* 드래그 핸들 */} - <div - {...attributes} - {...listeners} - className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" - > - <GripVertical className="w-5 h-5" /> - </div> - - {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */} - {index !== 0 ? ( - <Checkbox - checked={selected} - onCheckedChange={onSelect} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - <div className="w-4 h-4" /> // 기안자용 빈 공간 - )} - - {/* seq 표시 */} - <Badge variant="outline">{parseInt(seq) + 1}</Badge> - - {/* 그룹 상세 정보 */} - <div className="flex-1 grid grid-cols-3 gap-3"> - {/* 사용자 목록 */} - <div className="flex flex-col justify-center gap-1"> - {group.map((u) => ( - <div key={u.id} className="text-sm"> - {(u.name || 'Knox 이름 없음')}{u.deptName ? ` / ${u.deptName}` : ''} +function SortableApprovalGroup({ + group, + index, + form, + onRemoveGroup, + canRemove, + selected, + onSelect, +}: SortableApprovalGroupProps) { + const seq = group[0].seq; + const role = group[0].role; + // 그룹을 식별할 안정적인 고유 키(첫 구성원의 id 활용) + const groupKey = group[0].id; + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: groupKey }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( + <div + ref={setNodeRef} + style={style} + className={`flex items-center gap-3 p-3 border rounded-lg bg-white shadow-sm ${selected ? "ring-2 ring-primary" : ""}`} + > + {/* 드래그 핸들 */} + <div + {...attributes} + {...listeners} + className="cursor-grab hover:cursor-grabbing text-gray-400 hover:text-gray-600" + > + <GripVertical className="w-5 h-5" /> </div> - ))} - </div> - {/* 역할 */} - <div className="flex items-center"> - {seq === '0' ? ( - <Badge variant="secondary" className="w-full justify-center"> - 기안 - </Badge> - ) : role === '7' || role === '4' ? ( - <Badge variant="secondary" className="w-full justify-center"> - {getRoleText(role)} - </Badge> - ) : ( - // 단일일 때는 기존 토글 재사용 (첫 항목 기준) - <FormField - control={form.control} - name={`aplns.${form.getValues('aplns').findIndex((a) => a.id === group[0].id)}.role`} - render={({ field }) => ( - <ToggleGroup - type="single" - value={field.value} - onValueChange={field.onChange} - > - <ToggleGroupItem value="1">결재</ToggleGroupItem> - <ToggleGroupItem value="2">합의</ToggleGroupItem> - <ToggleGroupItem value="9">통보</ToggleGroupItem> - </ToggleGroup> - )} - /> - )} - </div> - - {/* 삭제 버튼 */} - <div className="flex items-center justify-end"> - {canRemove && ( - <Button type="button" variant="ghost" size="sm" onClick={onRemoveGroup}> - <Trash2 className="w-4 h-4" /> - </Button> - )} - </div> - </div> - </div> - ); -} + {/* 그룹 선택 체크박스 (상신자 제외하지만 공간은 확보) */} + {index !== 0 ? ( + <Checkbox + checked={selected} + onCheckedChange={onSelect} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <div className="w-4 h-4" /> // 기안자용 빈 공간 + )} -export default function ApprovalSubmit({ onSubmitSuccess }: ApprovalSubmitProps) { - const { data: session } = useSession(); - const [isSubmitting, setIsSubmitting] = useState(false); - const [submitResult, setSubmitResult] = useState<{ apInfId: string } | null>(null); + {/* seq 표시 */} + <Badge variant="outline">{parseInt(seq) + 1}</Badge> + + {/* 그룹 상세 정보 */} + <div className="flex-1 grid grid-cols-3 gap-3"> + {/* 사용자 목록 */} + <div className="flex flex-col justify-center gap-1"> + {group.map((u) => ( + <div key={u.id} className="text-sm"> + {u.name || "Knox 이름 없음"} + {u.deptName ? ` / ${u.deptName}` : ""} + </div> + ))} + </div> - const [selectedSeqs, setSelectedSeqs] = useState<string[]>([]); + {/* 역할 */} + <div className="flex items-center"> + {seq === "0" ? ( + <Badge + variant="secondary" + className="w-full justify-center" + > + 기안 + </Badge> + ) : role === "7" || role === "4" ? ( + <Badge + variant="secondary" + className="w-full justify-center" + > + {getRoleText(role)} + </Badge> + ) : ( + // 단일일 때는 기존 토글 재사용 (첫 항목 기준) + <FormField + control={form.control} + name={`aplns.${form.getValues("aplns").findIndex((a) => a.id === group[0].id)}.role`} + render={({ field }) => ( + <ToggleGroup + type="single" + value={field.value} + onValueChange={field.onChange} + > + <ToggleGroupItem value="1"> + 결재 + </ToggleGroupItem> + <ToggleGroupItem value="2"> + 합의 + </ToggleGroupItem> + <ToggleGroupItem value="9"> + 통보 + </ToggleGroupItem> + </ToggleGroup> + )} + /> + )} + </div> - // 그룹 단위 선택/해제 - const toggleSelectGroup = (seq: string) => { - setSelectedSeqs((prev) => - prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq] + {/* 삭제 버튼 */} + <div className="flex items-center justify-end"> + {canRemove && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={onRemoveGroup} + > + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </div> + </div> ); - }; - const clearSelection = () => setSelectedSeqs([]); - - const form = useForm<FormData>({ - resolver: zodResolver(formSchema), - defaultValues: { - subject: '', - contents: '', - contentsType: 'HTML', - docSecuType: 'PERSONAL', - urgYn: false, - importantYn: false, - notifyOption: '0', - docMngSaveCode: '0', - sbmLang: 'ko', - timeZone: 'GMT+9', - aplns: [], - attachments: undefined - } - }); - - const aplns = form.watch('aplns'); - - // 병렬 전환 핸들러 - const applyParallel = () => { - if (selectedSeqs.length < 2) { - toast.error('두 명 이상 선택해야 병렬 지정이 가능합니다.'); - return; - } - - const current = form.getValues('aplns'); - const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq)); - - const roles = Array.from(new Set(selectedAplns.map((a) => a.role))); - if (roles.length !== 1) { - toast.error('선택된 항목의 역할이 동일해야 합니다.'); - return; - } - - const role = roles[0]; - let newRole: ApprovalRole; - if (role === '1') { - newRole = '7'; // 병렬 결재 - } else if (role === '2') { - newRole = '4'; // 병렬 합의 - } else if (role === '9') { - newRole = '9'; // 병렬 통보(역할 코드 유지) - } else { - toast.error('결재, 합의 또는 통보만 병렬 지정 가능합니다.'); - return; - } - - const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq))); - - const updated = current.map((a) => { - if (selectedSeqs.includes(a.seq)) { - return { ...a, role: newRole as ApprovalRole, seq: minSeq.toString() }; - } - return a; - }); - - form.setValue('aplns', reorderBySeq(updated), { shouldDirty: true }); - clearSelection(); - }; - - // 후결 전환 핸들러 - const applyAfter = () => { - if (selectedSeqs.length !== 1) { - toast.error('후결은 한 명만 지정할 수 있습니다.'); - return; - } - - const targetSeq = selectedSeqs[0]; - - // 병렬 그룹(결재:7, 합의:4)은 후결 전환 불가 - const targetRole = form.getValues('aplns').find((a) => a.seq === targetSeq)?.role; - if (targetRole === '7' || targetRole === '4') { - toast.error('병렬 그룹은 후결로 전환할 수 없습니다.'); - return; - } - - const updated = form.getValues('aplns').map((a) => { - if (a.seq === targetSeq) { - return { ...a, role: (a.role === '3' ? '1' : '3') as ApprovalRole }; - } - return a; - }); - - form.setValue('aplns', reorderBySeq(updated), { shouldDirty: true }); - clearSelection(); - }; - - // 병렬 해제 핸들러 - const ungroupParallel = () => { - if (selectedSeqs.length === 0) { - toast.error('해제할 결재선을 선택하세요.'); - return; - } - - let newSeqCounter = 1; // 0은 상신자 유지 - const updated = form.getValues('aplns').map((a) => { - if (selectedSeqs.includes(a.seq)) { - let newRole: ApprovalRole = a.role; - if (a.role === '7') newRole = '1'; - if (a.role === '4') newRole = '2'; - - return { ...a, role: newRole, seq: '' }; // seq 임시 비움 - } - return { ...a }; - }); +} - // seq 재할당 (상신자 제외하고 순차) - const reassigned = updated - .sort((x, y) => parseInt(x.seq || '0') - parseInt(y.seq || '0')) - .map((a) => { - if (a.seq === '0') return a; // 상신자 - const newItem = { ...a, seq: newSeqCounter.toString() }; - newSeqCounter += 1; - return newItem; - }); - - form.setValue('aplns', reassigned as FormData['aplns'], { shouldDirty: true }); - clearSelection(); - }; - - // seq 기준 정렬 및 재번호 부여 (병렬 그룹은 동일 seq 유지) - const reorderBySeq = (list: FormData['aplns']): FormData['aplns'] => { - const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq)); - - // 기존 seq -> 새 seq 매핑. 병렬 그룹(동일 seq)은 동일한 새 seq 를 갖도록 처리 - const seqMap = new Map<string, string>(); - let nextSeq = 0; - - return sorted.map((apln) => { - if (!seqMap.has(apln.seq)) { - seqMap.set(apln.seq, nextSeq.toString()); - nextSeq += 1; - } - return { ...apln, seq: seqMap.get(apln.seq)! }; +export default function ApprovalSubmit({ + onSubmitSuccess, +}: ApprovalSubmitProps) { + const { data: session } = useSession(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitResult, setSubmitResult] = useState<{ + apInfId: string; + } | null>(null); + + const [selectedSeqs, setSelectedSeqs] = useState<string[]>([]); + + // 그룹 단위 선택/해제 + const toggleSelectGroup = (seq: string) => { + setSelectedSeqs((prev) => + prev.includes(seq) ? prev.filter((s) => s !== seq) : [...prev, seq], + ); + }; + const clearSelection = () => setSelectedSeqs([]); + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + subject: "", + contents: "", + contentsType: "HTML", + docSecuType: "PERSONAL", + urgYn: false, + importantYn: false, + notifyOption: "0", + docMngSaveCode: "0", + sbmLang: "ko", + timeZone: "GMT+9", + aplns: [], + attachments: undefined, + }, }); - }; - - // 로그인 사용자를 첫 번째 결재자로 보장하는 effect - useEffect(() => { - if (!session?.user) return; - - const currentEmail = session.user.email ?? ''; - const currentEpId = (session.user as { epId?: string }).epId; - const currentUserId = session.user.id ?? undefined; - let currentAplns = form.getValues('aplns'); + const aplns = form.watch("aplns"); - // 이미 포함되어 있는지 확인 (epId 또는 email 기준) - const selfIndex = currentAplns.findIndex( - (a) => (currentEpId && a.epId === currentEpId) || a.emailAddress === currentEmail - ); - - if (selfIndex === -1) { - // 맨 앞에 상신자 추가 - const newSelf: FormData['aplns'][number] = { - id: generateUniqueId(), - epId: currentEpId, - userId: currentUserId ? currentUserId.toString() : undefined, - emailAddress: currentEmail, - name: session.user.name ?? undefined, - role: '0', // 기안 - seq: '0', - opinion: '' - }; - - currentAplns = [newSelf, ...currentAplns]; - } - - // seq 재정렬 보장 - currentAplns = currentAplns.map((apln, idx) => ({ ...apln, seq: idx.toString() })); - - form.setValue('aplns', currentAplns, { shouldValidate: false, shouldDirty: true }); - }, [session, form]); - - // dnd-kit sensors - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - - // 고유 ID 생성 함수 - const generateUniqueId = () => { - return `apln-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - }; - - // 결재자 추가 (UserSelector를 통해) - const addApprovalUsers = (users: UserSelectItem[]) => { - const newAplns = [...aplns]; - - users.forEach((user) => { - // 이미 추가된 사용자인지 확인 - const existingIndex = newAplns.findIndex(apln => apln.userId === user.id.toString()); - if (existingIndex === -1) { - // 새 사용자 추가 - const newSeq = (newAplns.length).toString(); // 0은 상신자 - const newApln: FormData['aplns'][number] = { - id: generateUniqueId(), // 고유 ID 생성 - epId: (user as ExtendedUserSelectItem).epId, // epId가 전달되면 사용 - userId: user.id.toString(), - emailAddress: user.email, - name: user.name, - deptName: (user as ExtendedUserSelectItem).deptName ?? undefined, - role: '1', // 기본값: 결재 - seq: newSeq, - opinion: '' - }; - newAplns.push(newApln); - } - }); - - form.setValue('aplns', newAplns); - }; - - // 그룹 삭제 (seq 기반) - const removeApprovalGroup = (seq: string) => { - if (seq === '0') return; // 상신자 삭제 불가 - - const remaining = aplns.filter((a) => a.seq !== seq); - - // seq 재정렬 (병렬 그룹 유지) - const reordered = reorderBySeq(remaining); - form.setValue('aplns', reordered); - }; - - // 기존 단일 삭제 로직은 더 이상 사용하지 않음 (호환 위해 남겨둠) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const removeApprovalLine = (index: number) => { - // 첫 번째(상신자)는 삭제 불가 - if (index === 0) return; - - if (aplns.length > 1) { - const newAplns = aplns.filter((_: FormData['aplns'][number], i: number) => i !== index); - // 순서 재정렬 (ID는 유지) - const reorderedAplns = newAplns.map((apln: FormData['aplns'][number], i: number) => ({ - ...apln, - seq: (i).toString() - })); - form.setValue('aplns', reorderedAplns); - } - }; - - // 드래그앤드롭 핸들러 (그룹 이동 지원) - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (!over || active.id === over.id) return; - - // 현재 id는 그룹의 고유 key(첫 라인 id) - const activeKey = active.id as string; - const overKey = over.id as string; - - // key → seq 매핑 생성 - const idToSeq = new Map<string, string>(); - aplns.forEach((a) => { - // 같은 seq 내 첫 번째 id만 매핑하면 충분하지만, 안전하게 모두 매핑 - idToSeq.set(a.id, a.seq); - }); + // 병렬 전환 핸들러 + const applyParallel = () => { + if (selectedSeqs.length < 2) { + toast.error("두 명 이상 선택해야 병렬 지정이 가능합니다."); + return; + } - const activeSeq = idToSeq.get(activeKey); - const overSeq = idToSeq.get(overKey); + const current = form.getValues("aplns"); + const selectedAplns = current.filter((a) => selectedSeqs.includes(a.seq)); - if (!activeSeq || !overSeq) return; + const roles = Array.from(new Set(selectedAplns.map((a) => a.role))); + if (roles.length !== 1) { + toast.error("선택된 항목의 역할이 동일해야 합니다."); + return; + } - if (activeSeq === '0' || overSeq === '0') return; // 상신자는 이동 불가 + const role = roles[0]; + let newRole: ApprovalRole; + if (role === "1") { + newRole = "7"; // 병렬 결재 + } else if (role === "2") { + newRole = "4"; // 병렬 합의 + } else if (role === "9") { + newRole = "9"; // 병렬 통보(역할 코드 유지) + } else { + toast.error("결재, 합의 또는 통보만 병렬 지정 가능합니다."); + return; + } - // 현재 그룹 순서를 key 기반으로 계산 - const seqOrder = Array.from(new Set(aplns.map((a) => a.seq))); - const keyOrder = seqOrder.map((seq) => { - return aplns.find((a) => a.seq === seq)!.id; - }); + const minSeq = Math.min(...selectedAplns.map((a) => parseInt(a.seq))); - const oldIndex = keyOrder.indexOf(activeKey); - const newIndex = keyOrder.indexOf(overKey); + const updated = current.map((a) => { + if (selectedSeqs.includes(a.seq)) { + return { ...a, role: newRole as ApprovalRole, seq: minSeq.toString() }; + } + return a; + }); - const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex); + form.setValue("aplns", reorderBySeq(updated), { shouldDirty: true }); + clearSelection(); + }; - // key → 새 seq 매핑 - const keyToNewSeq = new Map<string, string>(); - newKeyOrder.forEach((k, idx) => { - keyToNewSeq.set(k, idx.toString()); - }); + // 후결 전환 핸들러 + const applyAfter = () => { + if (selectedSeqs.length !== 1) { + toast.error("후결은 한 명만 지정할 수 있습니다."); + return; + } - // aplns 재구성 + seq 재할당 - const updatedAplns: FormData['aplns'] = []; - newKeyOrder.forEach((k) => { - const oldSeq = idToSeq.get(k)!; - const groupItems = aplns.filter((a) => a.seq === oldSeq); - groupItems.forEach((item) => { - updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! }); - }); - }); + const targetSeq = selectedSeqs[0]; - form.setValue('aplns', updatedAplns, { shouldValidate: false, shouldDirty: true }); - }; - - const onSubmit = async (data: FormData) => { - setIsSubmitting(true); - setSubmitResult(null); - - try { - // 세션 정보 확인 - if (!session?.user) { - toast.error('로그인이 필요합니다.'); - return; - } - - const currentEmail = session.user.email ?? ''; - const currentEpId = (session.user as { epId?: string }).epId; - const currentUserId = session.user.id ?? ''; - - debugLog('Current User', session.user); - - if (!currentEpId) { - toast.error('사용자 정보가 올바르지 않습니다.'); - return; - } - - // 결재 경로 생성 (ID 제거하고 API 호출) - const approvalLines: ApprovalLine[] = await Promise.all( - data.aplns.map((apln) => - createApprovalLine( - { epId: apln.epId }, - apln.role, - apln.seq, - { opinion: apln.opinion } - ) - ) - ); - - debugLog('Approval Lines', approvalLines); - - // 상신 요청 생성 - const attachmentsArray = data.attachments ? Array.from(data.attachments as FileList) : undefined; - - const submitRequest: SubmitApprovalRequest = await createSubmitApprovalRequest( - data.contents, - data.subject, - approvalLines, - { - contentsType: data.contentsType, - docSecuType: data.docSecuType, - urgYn: data.urgYn ? 'Y' : 'N', - importantYn: data.importantYn ? 'Y' : 'N', - notifyOption: data.notifyOption, - docMngSaveCode: data.docMngSaveCode, - sbmLang: data.sbmLang, - timeZone: data.timeZone, - attachments: attachmentsArray + // 병렬 그룹(결재:7, 합의:4)은 후결 전환 불가 + const targetRole = form.getValues("aplns").find((a) => a.seq === targetSeq)?.role; + if (targetRole === "7" || targetRole === "4") { + toast.error("병렬 그룹은 후결로 전환할 수 없습니다."); + return; } - ); - - // API 호출 (보안 등급에 따라 분기) - const isSecure = data.docSecuType === 'CONFIDENTIAL' || data.docSecuType === 'CONFIDENTIAL_STRICT'; - - debugLog('Submit Request', submitRequest); - - const response = isSecure - ? await submitSecurityApproval(submitRequest) - : await submitApproval(submitRequest, { - userId: currentUserId, - epId: currentEpId, - emailAddress: currentEmail - }); - - debugLog('Submit Response', response); - - if (response.result === 'SUCCESS') { - setSubmitResult({ apInfId: response.data.apInfId }); - toast.success('결재가 성공적으로 상신되었습니다.'); - onSubmitSuccess?.(response.data.apInfId); - form.reset(); - } else { - toast.error(`결재 상신에 실패했습니다: ${response.result}`); - } - } catch (error) { - debugError('결재 상신 오류', error); - toast.error('결재 상신 중 오류가 발생했습니다.'); - } finally { - setIsSubmitting(false); - } - }; - - return ( - <Card className="w-full max-w-5xl"> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <FileText className="w-5 h-5" /> - 결재 상신 - </CardTitle> - <CardDescription> - 새로운 결재를 상신합니다. - </CardDescription> - </CardHeader> - - <CardContent className="space-y-6"> - {submitResult && ( - <div className="p-4 bg-green-50 border border-green-200 rounded-lg"> - <div className="flex items-center gap-2 text-green-700"> - <AlertCircle className="w-4 h-4" /> - <span className="font-medium">상신 완료</span> - </div> - <p className="text-sm text-green-600 mt-1"> - 결재 ID: {submitResult.apInfId} - </p> - </div> - )} - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> - {/* 기본 정보 */} - <div className="space-y-4"> - - <Separator /> - - {/* 결재 경로 */} - <div className="space-y-4"> - <h3 className="text-lg font-semibold">결재 경로</h3> - - {/* 상단 제어 버튼 */} - <div className="flex justify-end gap-2 mb-2"> - <Button variant="outline" size="sm" onClick={applyParallel}>병렬</Button> - <Button variant="outline" size="sm" onClick={applyAfter}>후결</Button> - <Button variant="outline" size="sm" onClick={ungroupParallel}>해제</Button> - </div> - {/* 결재자 추가 섹션 */} - <div className="p-4 border border-dashed border-gray-300 rounded-lg"> - <div className="mb-2"> - <label className="text-sm font-medium text-gray-700">결재자 추가</label> - <p className="text-xs text-gray-500">사용자를 검색하여 결재 라인에 추가하세요</p> - </div> - <UserSelector - selectedUsers={[]} - onUsersChange={addApprovalUsers} - placeholder="결재자를 검색하세요..." - domainFilter={{ type: "exclude", domains: ["partners"] }} - maxSelections={10} // 최대 10명까지 추가 가능 - /> - </div> + const updated = form.getValues("aplns").map((a) => { + if (a.seq === targetSeq) { + return { ...a, role: (a.role === "3" ? "1" : "3") as ApprovalRole }; + } + return a; + }); + + form.setValue("aplns", reorderBySeq(updated), { shouldDirty: true }); + clearSelection(); + }; + + // 병렬 해제 핸들러 + const ungroupParallel = () => { + if (selectedSeqs.length === 0) { + toast.error("해제할 결재선을 선택하세요."); + return; + } - {/* 그룹 기반 렌더링 */} - {aplns.length > 0 && ( - (() => { - const groups = Object.values( - aplns.reduce<Record<string, ApprovalLineItem[]>>((acc, apln) => { - acc[apln.seq] = acc[apln.seq] ? [...acc[apln.seq], apln] : [apln]; - return acc; - }, {}) - ).sort((a, b) => parseInt(a[0].seq) - parseInt(b[0].seq)); - - return ( - <DndContext - sensors={sensors} - collisionDetection={closestCenter} - modifiers={[restrictToVerticalAxis, restrictToParentElement]} - onDragEnd={handleDragEnd} - > - <SortableContext items={groups.map(g => g[0].id)} strategy={verticalListSortingStrategy}> - <div className="space-y-3"> - {groups.map((group, idx) => ( - <SortableApprovalGroup - key={group[0].id} - group={group} - index={idx} - form={form} - onRemoveGroup={() => removeApprovalGroup(group[0].seq)} - canRemove={idx !== 0 && aplns.length > 1} - selected={selectedSeqs.includes(group[0].seq)} - onSelect={() => toggleSelectGroup(group[0].seq)} - /> - ))} - </div> - </SortableContext> - </DndContext> - ); - })() - )} + let newSeqCounter = 1; // 0은 상신자 유지 + const updated = form.getValues("aplns").map((a) => { + if (selectedSeqs.includes(a.seq)) { + let newRole: ApprovalRole = a.role; + if (a.role === "7") newRole = "1"; + if (a.role === "4") newRole = "2"; + + return { ...a, role: newRole, seq: "" }; // seq 임시 비움 + } + return { ...a }; + }); + + // seq 재할당 (상신자 제외하고 순차) + const reassigned = updated + .sort((x, y) => parseInt(x.seq || "0") - parseInt(y.seq || "0")) + .map((a) => { + if (a.seq === "0") return a; // 상신자 + const newItem = { ...a, seq: newSeqCounter.toString() }; + newSeqCounter += 1; + return newItem; + }); + + form.setValue("aplns", reassigned as FormData["aplns"], { shouldDirty: true }); + clearSelection(); + }; + + // seq 기준 정렬 및 재번호 부여 (병렬 그룹은 동일 seq 유지) + const reorderBySeq = (list: FormData["aplns"]): FormData["aplns"] => { + const sorted = [...list].sort((a, b) => parseInt(a.seq) - parseInt(b.seq)); + + // 기존 seq -> 새 seq 매핑. 병렬 그룹(동일 seq)은 동일한 새 seq 를 갖도록 처리 + const seqMap = new Map<string, string>(); + let nextSeq = 0; + + return sorted.map((apln) => { + if (!seqMap.has(apln.seq)) { + seqMap.set(apln.seq, nextSeq.toString()); + nextSeq += 1; + } + return { ...apln, seq: seqMap.get(apln.seq)! }; + }); + }; + + // 로그인 사용자를 첫 번째 결재자로 보장하는 effect + useEffect(() => { + if (!session?.user) return; + + const currentEmail = session.user.email ?? ""; + const currentEpId = (session.user as { epId?: string }).epId; + const currentUserId = session.user.id ?? undefined; + + let currentAplns = form.getValues("aplns"); + + // 이미 포함되어 있는지 확인 (epId 또는 email 기준) + const selfIndex = currentAplns.findIndex( + (a) => (currentEpId && a.epId === currentEpId) || a.emailAddress === currentEmail, + ); + + if (selfIndex === -1) { + // 맨 앞에 상신자 추가 + const newSelf: FormData["aplns"][number] = { + id: generateUniqueId(), + epId: currentEpId, + userId: currentUserId ? currentUserId.toString() : undefined, + emailAddress: currentEmail, + name: session.user.name ?? undefined, + role: "0", // 기안 + seq: "0", + opinion: "", + }; + + currentAplns = [newSelf, ...currentAplns]; + } - {aplns.length === 0 && ( - <div className="text-center py-8 text-gray-500"> - <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" /> - <p>결재자를 추가해주세요</p> - </div> - )} - </div> - - <FormField - control={form.control} - name="subject" - render={({ field }) => ( - <FormItem> - <FormLabel>제목 *</FormLabel> - <FormControl> - <Input placeholder="결재 제목을 입력하세요" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="contents" - render={({ field }) => ( - <FormItem> - <FormLabel>내용 *</FormLabel> - <FormControl> - <RichTextEditor - value={field.value} - onChange={field.onChange} - height="400px" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */} - <div className="grid grid-cols-3 gap-4"> - {/* 보안 등급 */} - <FormField - control={form.control} - name="docSecuType" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <FormLabel>보안</FormLabel> - <FormControl> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <SelectTrigger className="w-24"> - <SelectValue placeholder="등급" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="PERSONAL">개인</SelectItem> - <SelectItem value="CONFIDENTIAL">기밀</SelectItem> - <SelectItem value="CONFIDENTIAL_STRICT">극기밀</SelectItem> - </SelectContent> - </Select> - </FormControl> - </FormItem> - )} - /> + // seq 재정렬 보장 + currentAplns = currentAplns.map((apln, idx) => ({ ...apln, seq: idx.toString() })); + + form.setValue("aplns", currentAplns, { shouldValidate: false, shouldDirty: true }); + }, [session, form]); + + // dnd-kit sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); - {/* 긴급 여부 */} - <FormField - control={form.control} - name="urgYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <FormLabel>긴급</FormLabel> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> + // 고유 ID 생성 함수 + const generateUniqueId = () => { + return `apln-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + }; + + // 결재자 추가 (UserSelector를 통해) + const addApprovalUsers = (users: UserSelectItem[]) => { + const newAplns = [...aplns]; + + users.forEach((user) => { + // 이미 추가된 사용자인지 확인 + const existingIndex = newAplns.findIndex(apln => apln.userId === user.id.toString()); + if (existingIndex === -1) { + // 새 사용자 추가 + const newSeq = (newAplns.length).toString(); // 0은 상신자 + const newApln: FormData["aplns"][number] = { + id: generateUniqueId(), // 고유 ID 생성 + epId: (user as ExtendedUserSelectItem).epId, // epId가 전달되면 사용 + userId: user.id.toString(), + emailAddress: user.email, + name: user.name, + deptName: (user as ExtendedUserSelectItem).deptName ?? undefined, + role: "1", // 기본값: 결재 + seq: newSeq, + opinion: "", + }; + newAplns.push(newApln); + } + }); + + form.setValue("aplns", newAplns); + }; + + // 그룹 삭제 (seq 기반) + const removeApprovalGroup = (seq: string) => { + if (seq === "0") return; // 상신자 삭제 불가 + + const remaining = aplns.filter((a) => a.seq !== seq); + + // seq 재정렬 (병렬 그룹 유지) + const reordered = reorderBySeq(remaining); + form.setValue("aplns", reordered); + }; + + // 기존 단일 삭제 로직은 더 이상 사용하지 않음 (호환 위해 남겨둠) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const removeApprovalLine = (index: number) => { + // 첫 번째(상신자)는 삭제 불가 + if (index === 0) return; + + if (aplns.length > 1) { + const newAplns = aplns.filter((_: FormData["aplns"][number], i: number) => i !== index); + // 순서 재정렬 (ID는 유지) + const reorderedAplns = newAplns.map((apln: FormData["aplns"][number], i: number) => ({ + ...apln, + seq: (i).toString(), + })); + form.setValue("aplns", reorderedAplns); + } + }; + + // 드래그앤드롭 핸들러 (그룹 이동 지원) + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over || active.id === over.id) return; + + // 현재 id는 그룹의 고유 key(첫 라인 id) + const activeKey = active.id as string; + const overKey = over.id as string; + + // key → seq 매핑 생성 + const idToSeq = new Map<string, string>(); + aplns.forEach((a) => { + // 같은 seq 내 첫 번째 id만 매핑하면 충분하지만, 안전하게 모두 매핑 + idToSeq.set(a.id, a.seq); + }); + + const activeSeq = idToSeq.get(activeKey); + const overSeq = idToSeq.get(overKey); + + if (!activeSeq || !overSeq) return; + + if (activeSeq === "0" || overSeq === "0") return; // 상신자는 이동 불가 + + // 현재 그룹 순서를 key 기반으로 계산 + const seqOrder = Array.from(new Set(aplns.map((a) => a.seq))); + const keyOrder = seqOrder.map((seq) => { + return aplns.find((a) => a.seq === seq)!.id; + }); + + const oldIndex = keyOrder.indexOf(activeKey); + const newIndex = keyOrder.indexOf(overKey); + + const newKeyOrder = arrayMove(keyOrder, oldIndex, newIndex); + + // key → 새 seq 매핑 + const keyToNewSeq = new Map<string, string>(); + newKeyOrder.forEach((k, idx) => { + keyToNewSeq.set(k, idx.toString()); + }); + + // aplns 재구성 + seq 재할당 + const updatedAplns: FormData["aplns"] = []; + newKeyOrder.forEach((k) => { + const oldSeq = idToSeq.get(k)!; + const groupItems = aplns.filter((a) => a.seq === oldSeq); + groupItems.forEach((item) => { + updatedAplns.push({ ...item, seq: keyToNewSeq.get(k)! }); + }); + }); + + form.setValue("aplns", updatedAplns, { shouldValidate: false, shouldDirty: true }); + }; + + const onSubmit = async (data: FormData) => { + setIsSubmitting(true); + setSubmitResult(null); + + try { + // 세션 정보 확인 + if (!session?.user) { + toast.error("로그인이 필요합니다."); + return; + } + + const currentEmail = session.user.email ?? ""; + const currentEpId = (session.user as { epId?: string }).epId; + const currentUserId = session.user.id ?? ""; + + debugLog("Current User", session.user); + + if (!currentEpId) { + toast.error("사용자 정보가 올바르지 않습니다."); + return; + } + + // 결재 경로 생성 (ID 제거하고 API 호출) + const approvalLines: ApprovalLine[] = await Promise.all( + data.aplns.map((apln) => + createApprovalLine( + { epId: apln.epId }, + apln.role, + apln.seq, + { opinion: apln.opinion }, + ), + ), + ); + + debugLog("Approval Lines", approvalLines); + + // 상신 요청 생성 + const attachmentsArray = data.attachments + ? Array.from(data.attachments as FileList) + : undefined; + + const submitRequest: SubmitApprovalRequest = + await createSubmitApprovalRequest( + data.contents, + data.subject, + approvalLines, + { + contentsType: data.contentsType, + docSecuType: data.docSecuType, + urgYn: data.urgYn ? "Y" : "N", + importantYn: data.importantYn ? "Y" : "N", + notifyOption: data.notifyOption, + docMngSaveCode: data.docMngSaveCode, + sbmLang: data.sbmLang, + timeZone: data.timeZone, + attachments: attachmentsArray, + }, + ); - {/* 중요 여부 */} - <FormField - control={form.control} - name="importantYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <FormLabel>중요</FormLabel> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - </div> - - {/* 첨부 파일 */} - <FormField - control={form.control} - name="attachments" - render={({ field }) => ( - <FormItem> - <FormLabel>첨부 파일</FormLabel> - <FormControl> - <Input - type="file" - multiple - onChange={(e) => field.onChange(e.target.files)} - /> - </FormControl> - <FormDescription>필요 시 파일을 선택하세요. (다중 선택 가능)</FormDescription> - <FormMessage /> - </FormItem> + // API 호출 (보안 등급에 따라 분기) + const isSecure = + data.docSecuType === "CONFIDENTIAL" || + data.docSecuType === "CONFIDENTIAL_STRICT"; + + debugLog("Submit Request", submitRequest); + + const response = isSecure + ? await submitSecurityApproval(submitRequest) + : await submitApproval(submitRequest, { + userId: currentUserId, + epId: currentEpId, + emailAddress: currentEmail, + }); + + debugLog("Submit Response", response); + + if (response.result === "SUCCESS") { + setSubmitResult({ apInfId: response.data.apInfId }); + toast.success("결재가 성공적으로 상신되었습니다."); + onSubmitSuccess?.(response.data.apInfId); + form.reset(); + } else { + toast.error(`결재 상신에 실패했습니다: ${response.result}`); + } + } catch (error) { + debugError("결재 상신 오류", error); + toast.error("결재 상신 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <Card className="w-full max-w-5xl"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 결재 상신 + </CardTitle> + <CardDescription>새로운 결재를 상신합니다.</CardDescription> + </CardHeader> + + <CardContent className="space-y-6"> + {submitResult && ( + <div className="p-4 bg-green-50 border border-green-200 rounded-lg"> + <div className="flex items-center gap-2 text-green-700"> + <AlertCircle className="w-4 h-4" /> + <span className="font-medium">상신 완료</span> + </div> + <p className="text-sm text-green-600 mt-1"> + 결재 ID: {submitResult.apInfId} + </p> + </div> )} - /> - </div> - - <Separator /> - - {/* 제출 버튼 */} - <div className="flex justify-end space-x-3"> - <Button - type="button" - variant="outline" - onClick={() => form.reset()} - disabled={isSubmitting} - > - 초기화 - </Button> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting ? ( - <> - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - 상신 중... - </> - ) : ( - '결재 상신' - )} - </Button> - </div> - </form> - </Form> - </CardContent> - </Card> - ); -}
\ No newline at end of file + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-6" + > + {/* 기본 정보 */} + <div className="space-y-4"> + <Separator /> + + {/* 결재 경로 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold"> + 결재 경로 + </h3> + + {/* 상단 제어 버튼 */} + <div className="flex justify-end gap-2 mb-2"> + <Button + type="button" + variant="outline" + size="sm" + onClick={applyParallel} + > + 병렬 + </Button> + <Button + type="button" + variant="outline" + size="sm" + onClick={applyAfter} + > + 후결 + </Button> + <Button + type="button" + variant="outline" + size="sm" + onClick={ungroupParallel} + > + 해제 + </Button> + </div> + + {/* 결재자 추가 섹션 */} + <div className="p-4 border border-dashed border-gray-300 rounded-lg"> + <div className="mb-2"> + <label className="text-sm font-medium text-gray-700"> + 결재자 추가 + </label> + <p className="text-xs text-gray-500"> + 사용자를 검색하여 결재 라인에 + 추가하세요 + </p> + </div> + <UserSelector + selectedUsers={[]} + onUsersChange={addApprovalUsers} + placeholder="결재자를 검색하세요..." + domainFilter={{ + type: "exclude", + domains: ["partners"], + }} + maxSelections={10} // 최대 10명까지 추가 가능 + /> + </div> + + {/* 그룹 기반 렌더링 */} + {aplns.length > 0 && + (() => { + const groups = Object.values( + aplns.reduce< + Record< + string, + ApprovalLineItem[] + > + >((acc, apln) => { + acc[apln.seq] = acc[apln.seq] + ? [...acc[apln.seq], apln] + : [apln]; + return acc; + }, {}), + ).sort( + (a, b) => + parseInt(a[0].seq) - + parseInt(b[0].seq), + ); + + return ( + <DndContext + sensors={sensors} + collisionDetection={ + closestCenter + } + modifiers={[ + restrictToVerticalAxis, + restrictToParentElement, + ]} + onDragEnd={handleDragEnd} + > + <SortableContext + items={groups.map( + (g) => g[0].id, + )} + strategy={ + verticalListSortingStrategy + } + > + <div className="space-y-3"> + {groups.map( + (group, idx) => ( + <SortableApprovalGroup + key={ + group[0] + .id + } + group={ + group + } + index={idx} + form={form} + onRemoveGroup={() => + removeApprovalGroup( + group[0] + .seq, + ) + } + canRemove={ + idx !== + 0 && + aplns.length > + 1 + } + selected={selectedSeqs.includes( + group[0] + .seq, + )} + onSelect={() => + toggleSelectGroup( + group[0] + .seq, + ) + } + /> + ), + )} + </div> + </SortableContext> + </DndContext> + ); + })()} + + {aplns.length === 0 && ( + <div className="text-center py-8 text-gray-500"> + <FileText className="w-12 h-12 mx-auto mb-2 opacity-50" /> + <p>결재자를 추가해주세요</p> + </div> + )} + </div> + + <FormField + control={form.control} + name="subject" + render={({ field }) => ( + <FormItem> + <FormLabel>제목 *</FormLabel> + <FormControl> + <Input + placeholder="결재 제목을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contents" + render={({ field }) => ( + <FormItem> + <FormLabel>내용 *</FormLabel> + <FormControl> + <RichTextEditor + value={field.value} + onChange={field.onChange} + height="400px" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 1 x 3 옵션 그리드 (보안 / 긴급 / 중요) */} + <div className="grid grid-cols-3 gap-4"> + {/* 보안 등급 */} + <FormField + control={form.control} + name="docSecuType" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <FormLabel>보안</FormLabel> + <FormControl> + <Select + onValueChange={ + field.onChange + } + defaultValue={field.value} + > + <SelectTrigger className="w-24"> + <SelectValue placeholder="등급" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="PERSONAL"> + 개인 + </SelectItem> + <SelectItem value="CONFIDENTIAL"> + 기밀 + </SelectItem> + <SelectItem value="CONFIDENTIAL_STRICT"> + 극기밀 + </SelectItem> + </SelectContent> + </Select> + </FormControl> + </FormItem> + )} + /> + + {/* 긴급 여부 */} + <FormField + control={form.control} + name="urgYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <FormLabel>긴급</FormLabel> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={ + field.onChange + } + /> + </FormControl> + </FormItem> + )} + /> + + {/* 중요 여부 */} + <FormField + control={form.control} + name="importantYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> + <FormLabel>중요</FormLabel> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={ + field.onChange + } + /> + </FormControl> + </FormItem> + )} + /> + </div> + + {/* 첨부 파일 */} + <FormField + control={form.control} + name="attachments" + render={({ field }) => ( + <FormItem> + <FormLabel>첨부 파일</FormLabel> + <FormControl> + <Input + type="file" + multiple + onChange={(e) => + field.onChange( + e.target.files, + ) + } + /> + </FormControl> + <FormDescription> + 필요 시 파일을 선택하세요. (다중 + 선택 가능) + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <Separator /> + + {/* 제출 버튼 */} + <div className="flex justify-end space-x-3"> + <Button + type="button" + variant="outline" + onClick={() => form.reset()} + disabled={isSubmitting} + > + 초기화 + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 상신 중... + </> + ) : ( + "결재 상신" + )} + </Button> + </div> + </form> + </Form> + </CardContent> + </Card> + ); +} diff --git a/components/layout/command-menu.tsx b/components/layout/command-menu.tsx index 5537a042..d0c9c49a 100644 --- a/components/layout/command-menu.tsx +++ b/components/layout/command-menu.tsx @@ -1,12 +1,13 @@ "use client" import * as React from "react" -import { useRouter,usePathname } from "next/navigation" +import { useRouter, usePathname, useParams } from "next/navigation" import { type DialogProps } from "@radix-ui/react-dialog" import { Circle, File, Laptop, Moon, Sun } from "lucide-react" import { useTheme } from "next-themes" import { MenuSection, mainNav, additionalNav, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; +import { useTranslation } from "@/i18n/client" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { @@ -24,6 +25,9 @@ export function CommandMenu({ ...props }: DialogProps) { const router = useRouter() const [open, setOpen] = React.useState(false) const { setTheme } = useTheme() + const params = useParams() + const lng = (params?.lng as string) || "evcp" + const { t } = useTranslation(lng, "menu") React.useEffect(() => { const down = (e: KeyboardEvent) => { @@ -81,20 +85,20 @@ const isPartnerRoute = pathname.includes("/partners"); <CommandList> <CommandEmpty>No results found.</CommandEmpty> - {main.map((group) => ( - <CommandGroup key={group.title} heading={group.title}> + {main.map((group: MenuSection) => ( + <CommandGroup key={group.titleKey} heading={t(group.titleKey)}> {group.items.map((navItem) => ( <CommandItem - key={navItem.title} - value={navItem.title} + key={`${navItem.titleKey}:${navItem.href}`} + value={t(navItem.titleKey)} onSelect={() => { - runCommand(() => router.push(navItem.href as string)) + runCommand(() => router.push(`/${lng}${navItem.href}`)) }} > <div className="mr-2 flex h-4 w-4 items-center justify-center"> <Circle className="h-3 w-3" /> </div> - {navItem.title} + {t(navItem.titleKey)} </CommandItem> ))} </CommandGroup> @@ -104,14 +108,14 @@ const isPartnerRoute = pathname.includes("/partners"); // .filter((navitem) => !navitem.external) .map((navItem) => ( <CommandItem - key={navItem.title} - value={navItem.title} + key={`${navItem.titleKey}:${navItem.href}`} + value={t(navItem.titleKey)} onSelect={() => { - runCommand(() => router.push(navItem.href as string)) + runCommand(() => router.push(`/${lng}${navItem.href}`)) }} > <File /> - {navItem.title} + {t(navItem.titleKey)} </CommandItem> ))} </CommandGroup> diff --git a/components/rich-text-editor/BlockquoteButton.tsx b/components/rich-text-editor/BlockquoteButton.tsx new file mode 100644 index 00000000..be9a342b --- /dev/null +++ b/components/rich-text-editor/BlockquoteButton.tsx @@ -0,0 +1,38 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Quote as QuoteIcon } from 'lucide-react' + +interface BlockquoteButtonProps { + editor: Editor | null + disabled?: boolean + isActive: boolean + executeCommand: (command: () => void) => void +} + +export function BlockquoteButton({ editor, disabled, isActive, executeCommand }: BlockquoteButtonProps) { + if (!editor) return null + return ( + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={isActive} + onMouseDown={e => e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBlockquote().run())} + disabled={disabled} + > + <QuoteIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>인용문</p> + </TooltipContent> + </Tooltip> + ) +} + + diff --git a/components/rich-text-editor/BulletListButton.tsx b/components/rich-text-editor/BulletListButton.tsx new file mode 100644 index 00000000..bf5b833c --- /dev/null +++ b/components/rich-text-editor/BulletListButton.tsx @@ -0,0 +1,46 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { List as ListIcon } from 'lucide-react' + +interface BulletListButtonProps { + editor: Editor | null + disabled?: boolean + isActive: boolean + executeCommand: (command: () => void) => void +} + +export function BulletListButton({ editor, disabled, isActive, executeCommand }: BulletListButtonProps) { + + + if (!editor) return null + + const handleToggleBulletList = () => { + console.log('toggleBulletList') + executeCommand(() => editor.chain().focus().toggleBulletList().run()) + } + + return ( + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={isActive} + onMouseDown={e => e.preventDefault()} + onPressedChange={handleToggleBulletList} + disabled={disabled} + > + <ListIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>글머리 기호</p> + </TooltipContent> + </Tooltip> + ) +} + + diff --git a/components/rich-text-editor/HistoryMenu.tsx b/components/rich-text-editor/HistoryMenu.tsx new file mode 100644 index 00000000..e5bb819c --- /dev/null +++ b/components/rich-text-editor/HistoryMenu.tsx @@ -0,0 +1,43 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Undo, Redo } from 'lucide-react' + +interface HistoryMenuProps { + editor: Editor | null + disabled?: boolean + executeCommand: (command: () => void) => void +} + +export function HistoryMenu({ editor, disabled, executeCommand }: HistoryMenuProps) { + if (!editor) return null + return ( + <> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={false} onPressedChange={() => executeCommand(() => editor.chain().focus().undo().run())} disabled={!editor.can().undo() || disabled}> + <Undo className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>실행 취소 (Ctrl+Z)</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={false} onPressedChange={() => executeCommand(() => editor.chain().focus().redo().run())} disabled={!editor.can().redo() || disabled}> + <Redo className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>다시 실행 (Ctrl+Y)</p> + </TooltipContent> + </Tooltip> + </> + ) +} + + diff --git a/components/rich-text-editor/InlineStyleMenu.tsx b/components/rich-text-editor/InlineStyleMenu.tsx new file mode 100644 index 00000000..02eac252 --- /dev/null +++ b/components/rich-text-editor/InlineStyleMenu.tsx @@ -0,0 +1,67 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Bold, Italic, Underline as UnderlineIcon, Strikethrough } from 'lucide-react' + +interface InlineStyleMenuProps { + editor: Editor | null + disabled?: boolean + isBold: boolean + isItalic: boolean + isUnderline: boolean + isStrike: boolean + executeCommand: (command: () => void) => void +} + +export function InlineStyleMenu({ editor, disabled, isBold, isItalic, isUnderline, isStrike, executeCommand }: InlineStyleMenuProps) { + if (!editor) return null + return ( + <> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isBold} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleBold().run())} disabled={disabled}> + <Bold className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>굵게 (Ctrl+B)</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isItalic} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleItalic().run())} disabled={disabled}> + <Italic className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>기울임 (Ctrl+I)</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isUnderline} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleUnderline().run())} disabled={disabled}> + <UnderlineIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>밑줄</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Toggle size="sm" pressed={isStrike} onPressedChange={() => executeCommand(() => editor.chain().focus().toggleStrike().run())} disabled={disabled}> + <Strikethrough className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>취소선</p> + </TooltipContent> + </Tooltip> + </> + ) +} + + diff --git a/components/rich-text-editor/OrderedListButton.tsx b/components/rich-text-editor/OrderedListButton.tsx new file mode 100644 index 00000000..f4f68729 --- /dev/null +++ b/components/rich-text-editor/OrderedListButton.tsx @@ -0,0 +1,38 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Toggle } from '@/components/ui/toggle' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ListOrdered as ListOrderedIcon } from 'lucide-react' + +interface OrderedListButtonProps { + editor: Editor | null + disabled?: boolean + isActive: boolean + executeCommand: (command: () => void) => void +} + +export function OrderedListButton({ editor, disabled, isActive, executeCommand }: OrderedListButtonProps) { + if (!editor) return null + return ( + <Tooltip> + <TooltipTrigger asChild> + <Toggle + size="sm" + pressed={isActive} + onMouseDown={e => e.preventDefault()} + onPressedChange={() => executeCommand(() => editor.chain().focus().toggleOrderedList().run())} + disabled={disabled} + > + <ListOrderedIcon className="h-4 w-4" /> + </Toggle> + </TooltipTrigger> + <TooltipContent> + <p>번호 매기기</p> + </TooltipContent> + </Tooltip> + ) +} + + diff --git a/components/rich-text-editor/RichTextEditor.tsx b/components/rich-text-editor/RichTextEditor.tsx index ceb76665..1360a5f8 100644 --- a/components/rich-text-editor/RichTextEditor.tsx +++ b/components/rich-text-editor/RichTextEditor.tsx @@ -1,17 +1,15 @@ 'use client' -import React, { useCallback, useRef, useState, useEffect } from 'react' +import React, { useRef, useEffect } from 'react' import { useEditor, EditorContent, type Editor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' -import { Image as TiptapImage } from '@tiptap/extension-image' -import Link from '@tiptap/extension-link' import TextAlign from '@tiptap/extension-text-align' import TextStyle from '@tiptap/extension-text-style' import Subscript from '@tiptap/extension-subscript' import Superscript from '@tiptap/extension-superscript' -import { Extension } from '@tiptap/core' +import Placeholder from '@tiptap/extension-placeholder' import Highlight from '@tiptap/extension-highlight' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' @@ -23,95 +21,23 @@ import Table from '@tiptap/extension-table' import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' +import { toast } from 'sonner' -// shadcn/ui & lucide -import { - Bold, - Italic, - Underline as UnderlineIcon, - Strikethrough, - ListOrdered, - List, - Quote, - Undo, - Redo, - Link as LinkIcon, - Image as ImageIcon, - AlignLeft, - AlignCenter, - AlignRight, - AlignJustify, - Subscript as SubscriptIcon, - Superscript as SuperscriptIcon, - Table as TableIcon, - Highlighter, - CheckSquare, - Type, -} from 'lucide-react' -import { Toggle } from '@/components/ui/toggle' -import { Separator } from '@/components/ui/separator' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' +import { FontSize } from './extensions/font-size' +import ImageResize from 'tiptap-extension-resize-image' +import { Toolbar } from './Toolbar' -/* ------------------------------------------------------------------------------------------------- - * FontSize extension (wraps TextStyle) - * -------------------------------------------------------------------------------------------------*/ -const FontSize = Extension.create({ - name: 'fontSize', - addGlobalAttributes() { - return [ - { - types: ['textStyle'], - attributes: { - fontSize: { - default: null, - parseHTML: element => { - const size = element.style.fontSize - return size ? size.replace(/[^0-9]/g, '') : null - }, - renderHTML: attributes => { - if (!attributes.fontSize) return {} - return { - style: `font-size: ${attributes.fontSize}`, - } - }, - }, - }, - }, - ] - }, -}) - -/* ------------------------------------------------------------------------------------------------- - * Props & component - * -------------------------------------------------------------------------------------------------*/ interface RichTextEditorProps { value: string onChange: (val: string) => void disabled?: boolean - height?: string // e.g. "400px" or "100%" + height?: string + className?: string + placeholder?: string + debounceMs?: number + onReady?: (editor: Editor) => void + onFocus?: () => void + onBlur?: () => void } export default function RichTextEditor({ @@ -119,51 +45,75 @@ export default function RichTextEditor({ onChange, disabled, height = '300px', + className, + placeholder, + debounceMs = 200, + onReady, + onFocus, + onBlur, }: RichTextEditorProps) { - // --------------------------------------------------------------------------- - // Editor instance - // --------------------------------------------------------------------------- + const updateTimerRef = useRef<number | undefined>(undefined) + + const computedExtensions: unknown[] = [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + listItem: false, + blockquote: false, + codeBlock: false, + code: false, + heading: { levels: [1, 2, 3] }, + horizontalRule: false, + }), + Underline, + ImageResize, + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], + defaultAlignment: 'left', + }), + Subscript, + Superscript, + TextStyle, + FontSize, + Table.configure({ resizable: true }), + TableRow, + TableCell, + TableHeader, + Highlight.configure({ multicolor: true }), + TaskList, + TaskItem.configure({ nested: true }), + BulletList.configure({ + HTMLAttributes: { + class: 'list-disc ml-5', + }, + }), + ListItem.configure({ + HTMLAttributes: { + class: 'list-item my-0.5', + }, + }), + OrderedList.configure({ + HTMLAttributes: { + class: 'list-decimal ml-5', + }, + }), + Blockquote.configure({ + HTMLAttributes: { + class: 'border-l-4 pl-4 my-3 italic', + }, + }), + ] + + if (placeholder) { + computedExtensions.push(Placeholder.configure({ placeholder })) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const extensionsForEditor = computedExtensions as any + const editor = useEditor({ - extensions: [ - StarterKit.configure({ - bulletList: false, - orderedList: false, - listItem: false, - blockquote: false, - codeBlock: false, - code: false, - heading: { levels: [1, 2, 3] }, - horizontalRule: false, - }), - Underline, - TiptapImage.configure({ - HTMLAttributes: { - class: 'max-w-full h-auto', - style: 'max-width: 600px; height: auto;', - }, - }), - Link.configure({ openOnClick: true, linkOnPaste: true }), - TextAlign.configure({ - types: ['heading', 'paragraph'], - alignments: ['left', 'center', 'right', 'justify'], - defaultAlignment: 'left', - }), - Subscript, - Superscript, - TextStyle, - FontSize, - Table.configure({ resizable: true }), - TableRow, - TableCell, - TableHeader, - Highlight.configure({ multicolor: true }), - TaskList, - TaskItem.configure({ nested: true }), - BulletList, - ListItem, - OrderedList, - Blockquote, - ], + extensions: extensionsForEditor, content: value, editable: !disabled, enablePasteRules: false, @@ -172,7 +122,7 @@ export default function RichTextEditor({ editorProps: { attributes: { class: - 'w-full h-full min-h-full bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 prose prose-sm max-w-none', + 'w-full h-full min-h-full bg-background px-3 py-2 text-sm leading-[1.6] ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 prose prose-sm max-w-none', }, handleDrop: (view, event, slice, moved) => { if (!moved && event.dataTransfer?.files.length) { @@ -194,805 +144,83 @@ export default function RichTextEditor({ } return false }, + handleDOMEvents: { + focus: () => { + onFocus?.() + return false + }, + blur: () => { + onBlur?.() + return false + }, + }, }, onUpdate: ({ editor }) => { - onChange(editor.getHTML()) + if (updateTimerRef.current) window.clearTimeout(updateTimerRef.current) + updateTimerRef.current = window.setTimeout(() => { + onChange(editor.getHTML()) + }, debounceMs) as unknown as number }, }) - // --------------------------------------------------------------------------- - // Image handling (base64) - // --------------------------------------------------------------------------- + useEffect(() => { + if (!editor) return + const current = editor.getHTML() + if (value !== current) { + editor.commands.setContent(value, false) + } + }, [editor, value]) + + useEffect(() => { + if (!editor) return + editor.setEditable(!disabled) + }, [editor, disabled]) + + const readyCalledRef = useRef(false) + useEffect(() => { + if (!editor || readyCalledRef.current) return + readyCalledRef.current = true + onReady?.(editor) + }, [editor, onReady]) + + const readFileAsDataURL = (file: File): Promise<string> => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = e => resolve(String(e.target?.result)) + reader.onerror = reject + reader.readAsDataURL(file) + }) + const handleImageUpload = async (file: File) => { if (file.size > 3 * 1024 * 1024) { - alert('이미지 크기는 3 MB 이하만 지원됩니다.') + toast.error('이미지 크기는 3 MB 이하만 지원됩니다.') return } if (!file.type.startsWith('image/')) { - alert('이미지 파일만 업로드 가능합니다.') + toast.error('이미지 파일만 업로드 가능합니다.') return } - const reader = new FileReader() - reader.onload = e => { - const base64 = e.target?.result as string - editor?.chain().focus().setImage({ src: base64, alt: file.name }).run() + try { + const dataUrl = await readFileAsDataURL(file) + editor?.chain().focus().setImage({ src: dataUrl, alt: file.name }).run() + } catch (error) { + console.error(error) + toast.error('이미지 읽기에 실패했습니다.') } - reader.onerror = () => alert('이미지 읽기에 실패했습니다.') - reader.readAsDataURL(file) } - // --------------------------------------------------------------------------- - // Toolbar (internal component) - // --------------------------------------------------------------------------- - const Toolbar: React.FC<{ editor: Editor | null; disabled?: boolean }> = ({ - editor, - disabled, - }) => { - const [fontSize, setFontSize] = useState('16') - const [isTableDialogOpen, setIsTableDialogOpen] = useState(false) - const [tableRows, setTableRows] = useState('3') - const [tableCols, setTableCols] = useState('3') - - // 간단한 툴바 상태 계산 - 실시간으로 계산하여 상태 동기화 문제 해결 - const getToolbarState = useCallback(() => { - if (!editor) return { - bold: false, - italic: false, - underline: false, - strike: false, - bulletList: false, - orderedList: false, - blockquote: false, - link: false, - highlight: false, - taskList: false, - table: false, - subscript: false, - superscript: false, - heading: false, - textAlign: 'left' as 'left' | 'center' | 'right' | 'justify', - } - - const textAlign = editor.isActive({ textAlign: 'center' }) - ? 'center' - : editor.isActive({ textAlign: 'right' }) - ? 'right' - : editor.isActive({ textAlign: 'justify' }) - ? 'justify' - : 'left' - - return { - bold: editor.isActive('bold'), - italic: editor.isActive('italic'), - underline: editor.isActive('underline'), - strike: editor.isActive('strike'), - bulletList: editor.isActive('bulletList'), - orderedList: editor.isActive('orderedList'), - blockquote: editor.isActive('blockquote'), - link: editor.isActive('link'), - highlight: editor.isActive('highlight'), - taskList: editor.isActive('taskList'), - table: editor.isActive('table'), - subscript: editor.isActive('subscript'), - superscript: editor.isActive('superscript'), - heading: [1, 2, 3, 4, 5, 6].some(l => editor.isActive('heading', { level: l })), - textAlign: textAlign as 'left' | 'center' | 'right' | 'justify', - } - }, [editor]) - - const toolbarState = getToolbarState() - - // 폰트 사이즈 업데이트 - 복잡한 timeout 로직 제거 - useEffect(() => { - if (!editor) return - - const updateFontSize = () => { - const currentFontSizeAttr = editor.getAttributes('textStyle').fontSize - if (typeof currentFontSizeAttr === 'string') { - const sizeValue = currentFontSizeAttr.replace('px', '') - setFontSize(sizeValue) - } else { - setFontSize('16') - } - } - - updateFontSize() - editor.on('selectionUpdate', updateFontSize) - editor.on('transaction', updateFontSize) - - return () => { - editor.off('selectionUpdate', updateFontSize) - editor.off('transaction', updateFontSize) - } - }, [editor]) - - // 개선된 executeCommand - 포커스 문제 해결 및 단순화 - const executeCommand = useCallback( - (command: () => void) => { - if (!editor || disabled) return - - // 명령 실행 전 포커스 확보 - if (!editor.isFocused) { - editor.commands.focus() - } - - // 명령 실행 - command() - - // 명령 실행 후 포커스 유지 - setTimeout(() => { - if (editor && !editor.isFocused) { - editor.commands.focus() - } - }, 10) - }, - [editor, disabled] - ) - - // 폰트 사이즈 입력 필드의 동적 width 계산 - const getFontSizeInputWidth = useCallback((size: string) => { - const length = size.length - return Math.max(length * 8 + 16, 40) // 최소 40px, 글자 수에 따라 증가 - }, []) - - if (!editor) return null - - // --- Render toolbar UI --- - return ( - <TooltipProvider> - <div className="border border-input bg-transparent rounded-t-md"> - <div className="flex flex-wrap gap-1 p-1"> - {/* 텍스트 스타일 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.bold} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleBold().run()) - } - disabled={disabled} - > - <Bold className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>굵게 (Ctrl+B)</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.italic} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleItalic().run()) - } - disabled={disabled} - > - <Italic className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>기울임 (Ctrl+I)</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.underline} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleUnderline().run()) - } - disabled={disabled} - > - <UnderlineIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>밑줄</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.strike} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleStrike().run()) - } - disabled={disabled} - > - <Strikethrough className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>취소선</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 제목 및 단락 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Toggle size="sm" pressed={toolbarState.heading} disabled={disabled}> - <Type className="h-4 w-4" /> - </Toggle> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - {([1, 2, 3] as Array<1 | 2 | 3>).map((level) => ( - <DropdownMenuItem - key={level} - onClick={() => - executeCommand(() => - editor.chain().focus().toggleHeading({ level }).run() - ) - } - className="flex items-center" - > - <span - className={`font-bold ${level === 1 ? 'text-xl' : level === 2 ? 'text-lg' : 'text-base' - }`} - > - 제목 {level} - </span> - </DropdownMenuItem> - ))} - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setParagraph().run()) - } - className="flex items-center" - > - <span>본문</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - - {/* 글자 크기 - 동적 width 적용 */} - <div className="flex items-center space-x-1"> - <Input - type="number" - min="8" - max="72" - value={fontSize} - onChange={(e) => { - const size = e.target.value - setFontSize(size) - if (size && parseInt(size) >= 8 && parseInt(size) <= 72) { - executeCommand(() => - editor - .chain() - .focus() - .setMark('textStyle', { fontSize: `${size}px` }) - .run() - ) - } - }} - style={{ width: `${getFontSizeInputWidth(fontSize)}px` }} - className="h-8 text-xs" - disabled={disabled} - /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> - <Type className="h-3 w-3" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map((size) => ( - <DropdownMenuItem - key={size} - onClick={() => { - setFontSize(size.toString()) - executeCommand(() => - editor - .chain() - .focus() - .setMark('textStyle', { fontSize: `${size}px` }) - .run() - ) - }} - className="flex items-center" - > - <span style={{ fontSize: `${Math.min(size, 16)}px` }}>{size}px</span> - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - </div> - - <Separator orientation="vertical" className="h-6" /> - - {/* 리스트 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.bulletList} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleBulletList().run()) - } - disabled={disabled} - > - <List className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>글머리 기호</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.orderedList} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleOrderedList().run()) - } - disabled={disabled} - > - <ListOrdered className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>번호 매기기</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.blockquote} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleBlockquote().run()) - } - disabled={disabled} - > - <Quote className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>인용문</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 텍스트 정렬 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Tooltip> - <TooltipTrigger asChild> - <Toggle size="sm" pressed={toolbarState.textAlign !== 'left'} disabled={disabled}> - {toolbarState.textAlign === 'center' ? ( - <AlignCenter className="h-4 w-4" /> - ) : toolbarState.textAlign === 'right' ? ( - <AlignRight className="h-4 w-4" /> - ) : toolbarState.textAlign === 'justify' ? ( - <AlignJustify className="h-4 w-4" /> - ) : ( - <AlignLeft className="h-4 w-4" /> - )} - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>텍스트 정렬</p> - </TooltipContent> - </Tooltip> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setTextAlign('left').run()) - } - className="flex items-center" - > - <AlignLeft className="mr-2 h-4 w-4" /> - <span>왼쪽 정렬</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setTextAlign('center').run()) - } - className="flex items-center" - > - <AlignCenter className="mr-2 h-4 w-4" /> - <span>가운데 정렬</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setTextAlign('right').run()) - } - className="flex items-center" - > - <AlignRight className="mr-2 h-4 w-4" /> - <span>오른쪽 정렬</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().setTextAlign('justify').run()) - } - className="flex items-center" - > - <AlignJustify className="mr-2 h-4 w-4" /> - <span>양쪽 정렬</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - - <Separator orientation="vertical" className="h-6" /> - - {/* 링크 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.link} - onPressedChange={() => { - if (toolbarState.link) { - executeCommand(() => editor.chain().focus().unsetLink().run()) - } else { - const url = window.prompt('URL을 입력하세요:') - if (url) { - executeCommand(() => editor.chain().focus().setLink({ href: url }).run()) - } - } - }} - disabled={disabled} - > - <LinkIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>링크 {toolbarState.link ? '제거' : '삽입'}</p> - </TooltipContent> - </Tooltip> - - {/* 이미지 업로드 */} - <Tooltip> - <TooltipTrigger asChild> - <div className="relative"> - <input - type="file" - accept="image/*" - className="hidden" - id="image-upload-rt" - onChange={(e) => { - const file = e.target.files?.[0] - if (file) handleImageUpload(file) - }} - /> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => { - document.getElementById('image-upload-rt')?.click() - }} - disabled={disabled} - > - <ImageIcon className="h-4 w-4" /> - </Toggle> - </div> - </TooltipTrigger> - <TooltipContent> - <p>이미지 삽입</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 첨자 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.subscript} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleSubscript().run()) - } - disabled={disabled} - > - <SubscriptIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>아래 첨자</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.superscript} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleSuperscript().run()) - } - disabled={disabled} - > - <SuperscriptIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>위 첨자</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 하이라이트 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.highlight} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleHighlight().run()) - } - disabled={disabled} - > - <Highlighter className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>하이라이트</p> - </TooltipContent> - </Tooltip> - - {/* 체크리스트 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={toolbarState.taskList} - onPressedChange={() => - executeCommand(() => editor.chain().focus().toggleTaskList().run()) - } - disabled={disabled} - > - <CheckSquare className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>체크리스트</p> - </TooltipContent> - </Tooltip> - - <Separator orientation="vertical" className="h-6" /> - - {/* 테이블 */} - {!toolbarState.table ? ( - <Dialog open={isTableDialogOpen} onOpenChange={setIsTableDialogOpen}> - <DialogTrigger asChild> - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => { - if (editor && editor.isActive('table')) { - alert('커서를 테이블 밖으로 이동시키세요') - return - } - setIsTableDialogOpen(true) - }} - disabled={disabled} - > - <TableIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>테이블 삽입</p> - </TooltipContent> - </Tooltip> - </DialogTrigger> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>테이블 크기 설정</DialogTitle> - <DialogDescription> - 생성할 테이블의 행과 열 수를 입력하세요 (1-20) - </DialogDescription> - </DialogHeader> - <div className="grid grid-cols-2 gap-4 py-4"> - <div className="space-y-2"> - <Label htmlFor="table-rows">행 수</Label> - <Input - id="table-rows" - type="number" - min="1" - max="20" - value={tableRows} - onChange={(e) => setTableRows(e.target.value)} - placeholder="3" - /> - </div> - <div className="space-y-2"> - <Label htmlFor="table-cols">열 수</Label> - <Input - id="table-cols" - type="number" - min="1" - max="20" - value={tableCols} - onChange={(e) => setTableCols(e.target.value)} - placeholder="3" - /> - </div> - </div> - <DialogFooter> - <Button variant="outline" onClick={() => setIsTableDialogOpen(false)}> - 취소 - </Button> - <Button - onClick={() => { - const rows = parseInt(tableRows, 10) - const cols = parseInt(tableCols, 10) - if (rows >= 1 && rows <= 20 && cols >= 1 && cols <= 20) { - executeCommand(() => - editor.chain().focus().insertTable({ rows, cols }).run() - ) - setIsTableDialogOpen(false) - } - }} - disabled={ - !tableRows || - !tableCols || - parseInt(tableRows, 10) < 1 || - parseInt(tableRows, 10) > 20 || - parseInt(tableCols, 10) < 1 || - parseInt(tableCols, 10) > 20 - } - > - 생성 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) : ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Tooltip> - <TooltipTrigger asChild> - <Toggle size="sm" pressed={true} disabled={disabled}> - <TableIcon className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>테이블 편집</p> - </TooltipContent> - </Tooltip> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addRowBefore().run()) - } - className="flex items-center" - > - <span>위에 행 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addRowAfter().run()) - } - className="flex items-center" - > - <span>아래에 행 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addColumnBefore().run()) - } - className="flex items-center" - > - <span>왼쪽에 열 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().addColumnAfter().run()) - } - className="flex items-center" - > - <span>오른쪽에 열 추가</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().deleteRow().run()) - } - className="flex items-center" - > - <span>행 삭제</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().deleteColumn().run()) - } - className="flex items-center" - > - <span>열 삭제</span> - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => - executeCommand(() => editor.chain().focus().deleteTable().run()) - } - className="flex items-center text-red-600" - > - <span>테이블 삭제</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - )} - - <Separator orientation="vertical" className="h-6" /> - - {/* 실행 취소/다시 실행 */} - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => - executeCommand(() => editor.chain().focus().undo().run()) - } - disabled={!editor.can().undo() || disabled} - > - <Undo className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>실행 취소 (Ctrl+Z)</p> - </TooltipContent> - </Tooltip> - - <Tooltip> - <TooltipTrigger asChild> - <Toggle - size="sm" - pressed={false} - onPressedChange={() => - executeCommand(() => editor.chain().focus().redo().run()) - } - disabled={!editor.can().redo() || disabled} - > - <Redo className="h-4 w-4" /> - </Toggle> - </TooltipTrigger> - <TooltipContent> - <p>다시 실행 (Ctrl+Y)</p> - </TooltipContent> - </Tooltip> - </div> - </div> - </TooltipProvider> - ) - } - - // --------------------------------------------------------------------------- - // Layout & rendering - // --------------------------------------------------------------------------- - const containerStyle = height === '100%' ? { height: '100%' } : { height } - const editorContentStyle = - height === '100%' ? { flex: 1, minHeight: 0 } : { height: `calc(${height} - 60px)` } + const containerStyle = { height } return ( - <div - className={`border rounded-md bg-background ${height === '100%' ? 'flex flex-col h-full' : ''}`} - style={containerStyle} - > - <div className="flex-shrink-0 border-b"> - <Toolbar editor={editor} disabled={disabled} /> + <div className={`border rounded-md bg-background flex flex-col ${className ?? ''}`} style={containerStyle}> + <div className="flex-none border-b"> + <Toolbar editor={editor} disabled={disabled} onSelectImageFile={handleImageUpload} /> </div> - <div className="overflow-y-auto" style={editorContentStyle}> + <div className="flex-1 min-h-0 overflow-y-auto"> <EditorContent editor={editor} className="h-full" /> </div> </div> ) -}
\ No newline at end of file +} + + diff --git a/components/rich-text-editor/StyleMenu.tsx b/components/rich-text-editor/StyleMenu.tsx new file mode 100644 index 00000000..a919e639 --- /dev/null +++ b/components/rich-text-editor/StyleMenu.tsx @@ -0,0 +1,65 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +interface StyleMenuProps { + editor: Editor | null + disabled?: boolean + executeCommand: (command: () => void) => void +} + +export function StyleMenu({ editor, disabled, executeCommand }: StyleMenuProps) { + if (!editor) return null + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild onMouseDown={e => e.preventDefault()}> + <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> + 스타일 + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem + className="flex items-center" + onSelect={() => + executeCommand(() => + editor + .chain() + .focus() + .setMark('textStyle', { fontSize: '32px' }) + .setBold() + .run() + ) + } + > + 제목 (굵게 + 32pt) + </DropdownMenuItem> + <DropdownMenuItem + className="flex items-center" + onSelect={() => + executeCommand(() => + editor + .chain() + .focus() + .unsetBold() + .setParagraph() + .setMark('textStyle', { fontSize: null as unknown as string }) + .run() + ) + } + > + 본문 (기본) + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +} + + diff --git a/components/rich-text-editor/TextAlignMenu.tsx b/components/rich-text-editor/TextAlignMenu.tsx new file mode 100644 index 00000000..98cc0d4c --- /dev/null +++ b/components/rich-text-editor/TextAlignMenu.tsx @@ -0,0 +1,46 @@ +'use client' + +import React from 'react' +import type { Editor } from '@tiptap/react' +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { AlignCenter, AlignJustify, AlignLeft, AlignRight } from 'lucide-react' + +interface TextAlignMenuProps { + editor: Editor + disabled?: boolean + currentAlign?: 'left' | 'center' | 'right' | 'justify' + executeCommand: (command: () => void) => void +} + +export function TextAlignMenu({ editor, disabled, executeCommand }: TextAlignMenuProps) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild onMouseDown={e => e.preventDefault()}> + <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> + 정렬 + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('left').run())} className="flex items-center"> + <AlignLeft className="mr-2 h-4 w-4" /> + <span>왼쪽 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('center').run())} className="flex items-center"> + <AlignCenter className="mr-2 h-4 w-4" /> + <span>가운데 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('right').run())} className="flex items-center"> + <AlignRight className="mr-2 h-4 w-4" /> + <span>오른쪽 정렬</span> + </DropdownMenuItem> + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setTextAlign('justify').run())} className="flex items-center"> + <AlignJustify className="mr-2 h-4 w-4" /> + <span>양쪽 정렬</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) +} + + diff --git a/components/rich-text-editor/Toolbar.tsx b/components/rich-text-editor/Toolbar.tsx new file mode 100644 index 00000000..13e31c24 --- /dev/null +++ b/components/rich-text-editor/Toolbar.tsx @@ -0,0 +1,350 @@ +'use client' + +import React, { useCallback, useEffect, useId, useReducer, useState } from 'react' +import type { Editor } from '@tiptap/react' + +import { Image as ImageIcon, Type } from 'lucide-react' + +import { Toggle } from '@/components/ui/toggle' +import { Separator } from '@/components/ui/separator' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { TextAlignMenu } from './TextAlignMenu' +import { StyleMenu } from './StyleMenu' +import { InlineStyleMenu } from './InlineStyleMenu' +import { BulletListButton } from './BulletListButton' +import { OrderedListButton } from './OrderedListButton' +import { BlockquoteButton } from './BlockquoteButton' +import { HistoryMenu } from './HistoryMenu' + +interface ToolbarProps { + editor: Editor | null + disabled?: boolean + onSelectImageFile?: (file: File) => void +} + +export function Toolbar({ editor, disabled, onSelectImageFile }: ToolbarProps) { + const [fontSize, setFontSize] = useState('16') + const [imageWidthPct, setImageWidthPct] = useState<string>('100') + + const imageInputId = useId() + + const getToolbarState = useCallback(() => { + if (!editor) + return { + bold: false, + italic: false, + underline: false, + strike: false, + bulletList: false, + orderedList: false, + blockquote: false, + highlight: false, + taskList: false, + table: false, + subscript: false, + superscript: false, + heading: false, + textAlign: 'left' as 'left' | 'center' | 'right' | 'justify', + } + + const textAlign = editor.isActive({ textAlign: 'center' }) + ? 'center' + : editor.isActive({ textAlign: 'right' }) + ? 'right' + : editor.isActive({ textAlign: 'justify' }) + ? 'justify' + : 'left' + + return { + bold: editor.isActive('bold'), + italic: editor.isActive('italic'), + underline: editor.isActive('underline'), + strike: editor.isActive('strike'), + bulletList: editor.isActive('bulletList'), + orderedList: editor.isActive('orderedList'), + blockquote: editor.isActive('blockquote'), + highlight: editor.isActive('highlight'), + taskList: editor.isActive('taskList'), + table: editor.isActive('table'), + subscript: editor.isActive('subscript'), + superscript: editor.isActive('superscript'), + heading: [1, 2, 3, 4, 5, 6].some(l => editor.isActive('heading', { level: l })), + textAlign: textAlign as 'left' | 'center' | 'right' | 'justify', + } + }, [editor]) + + const toolbarState = getToolbarState() + + useEffect(() => { + if (!editor) return + const updateFontSize = () => { + const currentFontSizeAttr = editor.getAttributes('textStyle').fontSize + if (typeof currentFontSizeAttr === 'string') { + const sizeValue = currentFontSizeAttr.replace('px', '') + setFontSize(sizeValue) + } else { + try { + const from = editor.state.selection.from + const dom = editor.view.domAtPos(from).node as HTMLElement + const el = dom.nodeType === 3 ? (dom.parentElement as HTMLElement) : (dom as HTMLElement) + const computed = window.getComputedStyle(el) + const val = computed.fontSize.replace('px', '') + setFontSize(val || '16') + } catch { + setFontSize('16') + } + } + } + updateFontSize() + editor.on('selectionUpdate', updateFontSize) + editor.on('transaction', updateFontSize) + return () => { + editor.off('selectionUpdate', updateFontSize) + editor.off('transaction', updateFontSize) + } + }, [editor]) + + const [, forceRender] = useReducer((x: number) => x + 1, 0) + const executeCommand = useCallback( + (command: () => void) => { + if (!editor || disabled) return + if (!editor.isFocused) editor.commands.focus() + command() + forceRender() + setTimeout(() => { + if (editor && !editor.isFocused) editor.commands.focus() + forceRender() + }, 0) + }, + [editor, disabled] + ) + + useEffect(() => { + if (!editor) return + const updateImageWidth = () => { + if (editor.isActive('image')) { + const width = editor.getAttributes('image').width as string | undefined + if (typeof width === 'string') { + const pct = width.endsWith('%') ? width.replace('%', '') : width.replace('px', '') + setImageWidthPct(pct) + } else { + setImageWidthPct('100') + } + } + } + updateImageWidth() + editor.on('selectionUpdate', updateImageWidth) + editor.on('transaction', updateImageWidth) + return () => { + editor.off('selectionUpdate', updateImageWidth) + editor.off('transaction', updateImageWidth) + } + }, [editor]) + + if (!editor) return null + + return ( + <TooltipProvider> + <div className="border border-input bg-transparent rounded-t-md"> + <div className="flex flex-wrap gap-1 p-1"> + <StyleMenu editor={editor} disabled={disabled} executeCommand={executeCommand} /> + + <InlineStyleMenu + editor={editor} + disabled={disabled} + isBold={toolbarState.bold} + isItalic={toolbarState.italic} + isUnderline={toolbarState.underline} + isStrike={toolbarState.strike} + executeCommand={executeCommand} + /> + + <Separator orientation="vertical" className="h-6" /> + + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Toggle size="sm" pressed={toolbarState.heading} disabled={disabled}> + <Type className="h-4 w-4" /> + </Toggle> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + {([1, 2, 3] as Array<1 | 2 | 3>).map(level => ( + <DropdownMenuItem + key={level} + onSelect={() => executeCommand(() => editor.chain().focus().toggleHeading({ level }).run())} + className="flex items-center" + > + <span className={`font-bold ${level === 1 ? 'text-xl' : level === 2 ? 'text-lg' : 'text-base'}`}> + 제목 {level} + </span> + </DropdownMenuItem> + ))} + <DropdownMenuItem onSelect={() => executeCommand(() => editor.chain().focus().setParagraph().run())} className="flex items-center"> + <span>본문</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + <div className="flex items-center gap-1"> + <Input + type="number" + min="8" + max="72" + value={fontSize} + onChange={e => { + const size = e.target.value + setFontSize(size) + if (size && parseInt(size) >= 8 && parseInt(size) <= 72) { + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run()) + } + }} + style={{ width: `64px` }} + className="h-8 text-xs text-right" + disabled={disabled} + /> + <div className="flex items-center gap-1"> + <Button + type="button" + size="sm" + variant="outline" + className="h-8 w-8 p-0" + onMouseDown={e => e.preventDefault()} + onClick={() => { + const next = Math.max(8, Math.min(72, parseInt(fontSize || '16', 10) - 1)) + setFontSize(String(next)) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${next}px` }).run()) + }} + disabled={disabled} + > + - + </Button> + <Button + type="button" + size="sm" + variant="outline" + className="h-8 w-8 p-0" + onMouseDown={e => e.preventDefault()} + onClick={() => { + const next = Math.max(8, Math.min(72, parseInt(fontSize || '16', 10) + 1)) + setFontSize(String(next)) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${next}px` }).run()) + }} + disabled={disabled} + > + + + </Button> + </div> + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="h-8 px-2" disabled={disabled}> + <Type className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + {[8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72].map(size => ( + <DropdownMenuItem + key={size} + onSelect={() => { + setFontSize(size.toString()) + executeCommand(() => editor.chain().focus().setMark('textStyle', { fontSize: `${size}px` }).run()) + }} + className="flex items-center" + > + <span style={{ fontSize: `${Math.min(size, 16)}px` }}>{size}px</span> + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + + <Separator orientation="vertical" className="h-6" /> + + <div className="flex items-center gap-1"> + <BulletListButton editor={editor} disabled={disabled} isActive={toolbarState.bulletList} executeCommand={executeCommand} /> + <OrderedListButton editor={editor} disabled={disabled} isActive={toolbarState.orderedList} executeCommand={executeCommand} /> + <BlockquoteButton editor={editor} disabled={disabled} isActive={toolbarState.blockquote} executeCommand={executeCommand} /> + </div> + + <Separator orientation="vertical" className="h-6" /> + + <TextAlignMenu editor={editor} disabled={disabled} executeCommand={executeCommand} /> + + <Separator orientation="vertical" className="h-6" /> + + <Tooltip> + <TooltipTrigger asChild> + <div className="relative"> + <input + type="file" + accept="image/*" + className="hidden" + id={`image-upload-rt-${imageInputId}`} + onChange={e => { + const file = e.target.files?.[0] + if (file) onSelectImageFile?.(file) + }} + /> + <Toggle + size="sm" + pressed={false} + onPressedChange={() => { + const el = document.getElementById(`image-upload-rt-${imageInputId}`) as HTMLInputElement | null + el?.click() + }} + disabled={disabled} + aria-label="이미지 삽입" + > + <ImageIcon className="h-4 w-4" /> + </Toggle> + </div> + </TooltipTrigger> + <TooltipContent> + <p>이미지 삽입</p> + </TooltipContent> + </Tooltip> + + {editor.isActive('image') && ( + <div className="flex items-center gap-1 ml-1"> + <Label className="text-xs">너비</Label> + <Input + type="number" + min={5} + max={100} + value={imageWidthPct} + onChange={e => { + const val = e.target.value + setImageWidthPct(val) + const pct = Math.min(100, Math.max(5, parseInt(val || '0', 10))) + executeCommand(() => editor.chain().focus().updateAttributes('image', { width: `${pct}%` }).run()) + }} + className="h-8 w-16 text-xs" + disabled={disabled} + /> + <span className="text-xs">%</span> + </div> + )} + + <Separator orientation="vertical" className="h-6" /> + + <HistoryMenu editor={editor} disabled={disabled} executeCommand={executeCommand} /> + </div> + </div> + </TooltipProvider> + ) +} + + diff --git a/components/rich-text-editor/extensions/font-size.ts b/components/rich-text-editor/extensions/font-size.ts new file mode 100644 index 00000000..1b7e2700 --- /dev/null +++ b/components/rich-text-editor/extensions/font-size.ts @@ -0,0 +1,31 @@ +import { Extension } from '@tiptap/core' + +export const FontSize = Extension.create({ + name: 'fontSize', + addGlobalAttributes() { + return [ + { + types: ['textStyle'], + attributes: { + fontSize: { + default: null, + parseHTML: element => { + const sizeWithUnit = (element as HTMLElement).style.fontSize + return sizeWithUnit || null + }, + renderHTML: attributes => { + if (!attributes.fontSize) return {} + const value = String(attributes.fontSize) + const withUnit = /(px|em|rem|%)$/i.test(value) ? value : `${value}px` + return { + style: `font-size: ${withUnit}`, + } + }, + }, + }, + }, + ] + }, +}) + + diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 6cd4ae96..dd10e895 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -367,18 +367,23 @@ export const mainNav: MenuSection[] = [ groupKey: "groups.interface" }, { + titleKey: "menu.information_system.approval_template", + href: "/evcp/approval/template", + groupKey: "groups.approval" + }, + { titleKey: "menu.information_system.approval_log", - href: "/evcp/approval-log", + href: "/evcp/approval/log", groupKey: "groups.approval" }, { - titleKey: "menu.information_system.approval_path", - href: "/evcp/approval-path", + titleKey: "menu.information_system.approval_line", + href: "/evcp/approval/line", groupKey: "groups.approval" }, { titleKey: "menu.information_system.approval_after", - href: "/evcp/approval-after", + href: "/evcp/approval/after", groupKey: "groups.approval" }, { diff --git a/db/schema/knox/approvals.ts b/db/schema/knox/approvals.ts index 27332ed6..0f8ee90a 100644 --- a/db/schema/knox/approvals.ts +++ b/db/schema/knox/approvals.ts @@ -1,7 +1,36 @@ -import { boolean, jsonb, text, timestamp, } from "drizzle-orm/pg-core"; +import { boolean, jsonb, text, timestamp, integer, uuid } from "drizzle-orm/pg-core"; import { knoxSchema } from "./employee"; +import { users } from '@/db/schema/users'; -export const approval = knoxSchema.table("approval", { +/** + * 결재 관련 테이블 정의 + * + * 템플릿 관리 + * 결재선 관리 + * 결재 로그 관리 (히스토리 관리) + * + */ + +// 결재 템플릿 히스토리 +export const approvalTemplateHistory = knoxSchema.table('approval_template_history', { + id: uuid().primaryKey().defaultRandom(), // 히스토리 아이디 UUID + templateId: uuid() + .references(() => approvalTemplates.id, { onDelete: 'cascade' }) + .notNull(), + version: integer().notNull(), // 히스토리 버전 + subject: text().notNull(), // 템플릿 제목 + content: text().notNull(), // 템플릿 내용 + changeDescription: text(), // 변경 사항 설명 + changedBy: integer() // 변경자 - eVCP 유저 아이디 기반 참조 + .notNull() + .references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp().defaultNow().notNull(), + // 히스토리는 업데이트 없음. 생성 시점만 기록. +}); + + +// 실제 결재 상신 로그 +export const approvalLogs = knoxSchema.table("approval_logs", { apInfId: text("ap_inf_id").primaryKey(), userId: text("user_id").notNull(), epId: text("ep_id").notNull(), @@ -9,8 +38,69 @@ export const approval = knoxSchema.table("approval", { subject: text("subject").notNull(), content: text("content").notNull(), status: text("status").notNull(), - aplns: jsonb("aplns").notNull(), + aplns: jsonb("aplns").notNull(), // approval lines = 결재선 isDeleted: boolean("is_deleted").notNull().default(false), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), -});
\ No newline at end of file +}); + + +// 결재 템플릿 +export const approvalTemplates = knoxSchema.table('approval_templates', { + id: uuid().primaryKey().defaultRandom(), // 템플릿 아이디 UUID + name: text().notNull(), // 템플릿 이름 + // slug: text('slug').notNull().unique(), // 템플릿 슬러그는 UUID로 대체하기 + subject: text().notNull(), // 템플릿 제목 + content: text().notNull(), // 템플릿 내용 + description: text(), // 템플릿 설명 + category: text(), // 템플릿 카테고리 설명 + + // 선택된 결재선 참조 (nullable, 결재선은 별도에서 관리) + approvalLineId: uuid().references(() => approvalLines.id, { onDelete: 'set null' }), + + // 메타데이터 + createdBy: integer() // 템플릿 생성자 - eVCP 유저 아이디 기반 참조 + .notNull() + .references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp().defaultNow().notNull(), + updatedAt: timestamp().defaultNow().notNull(), +}); + +// 결재선 템플릿 +export const approvalLines = knoxSchema.table('approval_lines', { + id: uuid().primaryKey().defaultRandom(), // 결재선 아이디 UUID + name: text().notNull(), // 결재선 이름 + description: text(), // 결재선 설명 + category: text(), + + // 핵심 + aplns: jsonb().notNull(), // 결재선 구성 + + // 메타데이터 + createdBy: integer() // 결재선 생성자 - eVCP 유저 아이디 기반 참조 + .notNull() + .references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp().defaultNow().notNull(), + updatedAt: timestamp().defaultNow().notNull(), +}); + +// + +// 결재 템플릿 변수 +export const approvalTemplateVariables = knoxSchema.table('approval_template_variables', { + id: uuid().primaryKey().defaultRandom(), // 변수 아이디 UUID + approvalTemplateId: uuid() + .references(() => approvalTemplates.id, { onDelete: 'cascade' }), + variableName: text().notNull(), // 변수 이름 + variableType: text().notNull(), // 변수 타입 + defaultValue: text(), // 변수 기본값 + description: text(), // 변수 설명 + + // 메타데이터 + createdBy: integer() // 변수 생성자 - eVCP 유저 아이디 기반 참조 + .notNull() + .references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp().defaultNow().notNull(), + updatedAt: timestamp().defaultNow().notNull(), +}); + diff --git a/db/schema/procurementRFQ.ts b/db/schema/procurementRFQ.ts index 18cf5f9d..fe60bb0e 100644 --- a/db/schema/procurementRFQ.ts +++ b/db/schema/procurementRFQ.ts @@ -1,5 +1,5 @@ import { foreignKey, pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, check } from "drizzle-orm/pg-core"; -import { eq, sql, and, or, relations } from "drizzle-orm"; +import { eq, sql, relations } from "drizzle-orm"; import { projects } from "./projects"; import { users } from "./users"; import { vendors } from "./vendors"; @@ -12,25 +12,25 @@ export const procurementRfqs = pgTable( // RFQ 고유 코드 rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001" - // 프로젝트 참조 - projectId: integer("project_id") - .references(() => projects.id, { onDelete: "set null" }), + // 프로젝트: ECC RFQ는 프로젝트 테이블과 1:N 관계를 가져야 함 + // WHY?: 여러 프로젝트 혹은 여러 시리즈의 동일 품목을 PR로 묶어 올리기 때문 + projectId: varchar("project_id", { length: 1000 }), + // SS, II, null 값을 가질 수 있음. + // SS = 시리즈 통합, II = 품목 통합, 공란 = 통합 없음 series: varchar("series", { length: 50 }), - // itemId: integer("item_id") - // .notNull() - // .references(() => items.id, { onDelete: "cascade" }), - - itemCode: varchar("item_code", { length: 100 }), - itemName: varchar("item_name", { length: 255 }), + // 자재코드, 자재명: ECC RFQ는 자재코드, 자재명을 가지지 않음 + // WHY?: 여러 프로젝트 혹은 여러 시리즈의 동일 품목을 PR로 묶어 올리기 때문 + // 아래 컬럼은 대표 자재코드, 대표 자재명으로 사용 + itemCode: varchar("item_code", { length: 100 }), + itemName: varchar("item_name", { length: 255 }), dueDate: date("due_date", { mode: "date" }) - .$type<Date>() - .notNull(), + .$type<Date>(), // 인터페이스한 값은 dueDate가 없으므로 notNull 제약조건 제거 rfqSendDate: date("rfq_send_date", { mode: "date" }) - .$type<Date | null>(), // notNull() 제약조건 제거, null 허용 + .$type<Date | null>(), // notNull() 제약조건 제거, null 허용 (ECC에서 수신 후 보내지 않은 RFQ) status: varchar("status", { length: 30 }) .$type<"RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "PO Transfer" | "PO Create">() @@ -38,7 +38,7 @@ export const procurementRfqs = pgTable( .notNull(), rfqSealedYn: boolean("rfq_sealed_yn").default(false), - picCode: varchar("pic_code", { length: 50 }), + picCode: varchar("pic_code", { length: 50 }), // 구매그룹에 대응시킴 (담당자 코드로 3자리) remark: text("remark"), // 생성자 diff --git a/db/schema/projects.ts b/db/schema/projects.ts index a67b2c33..ee3dbf27 100644 --- a/db/schema/projects.ts +++ b/db/schema/projects.ts @@ -5,7 +5,7 @@ export const projects = pgTable("projects", { code: varchar("code", { length: 50 }).notNull(), name: text("name").notNull(), type: varchar("type", { length: 20 }).default("ship").notNull(), - + pspid: char('pspid', { length: 24 }).unique(), // 프로젝트ID (ECC), TODO: 매핑 필요 createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }) diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json index d07293f6..7b59d34a 100644 --- a/i18n/locales/en/menu.json +++ b/i18n/locales/en/menu.json @@ -149,13 +149,15 @@ "integration_list": "Interface List Management", "integration_log": "Interface History Inquiry", "approval_log": "Approval History Inquiry", - "approval_path": "Approval Path Management", + "approval_template": "Approval Template Management", + "approval_line": "Approval Line Management", "approval_after": "Post-Approval Management", "email_template": "Email Template Management", "email_receiver": "Email Recipient Management", "email_log": "Email Transmission History Inquiry", "login_history": "Login/Logout History Inquiry", "page_visits": "Page Access History Inquiry" + }, "vendor": { "sales": { diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json index 469b811d..9e3fe7a2 100644 --- a/i18n/locales/ko/menu.json +++ b/i18n/locales/ko/menu.json @@ -149,7 +149,8 @@ "integration_list": "인터페이스 목록 관리", "integration_log": "인터페이스 이력 조회", "approval_log": "결재 이력 조회", - "approval_path": "결재 경로 관리", + "approval_template": "결재 서식 관리", + "approval_line": "결재선 관리", "approval_after": "결재 후처리 관리", "email_template": "이메일 서식 관리", "email_receiver": "이메일 수신인 관리", diff --git a/lib/approval-line/service.ts b/lib/approval-line/service.ts new file mode 100644 index 00000000..3000e25f --- /dev/null +++ b/lib/approval-line/service.ts @@ -0,0 +1,341 @@ +'use server'; + +import db from '@/db/db'; +import { + and, + asc, + count, + desc, + eq, + ilike, + or, +} from 'drizzle-orm'; + +import { sql } from 'drizzle-orm'; +import { approvalLines } from '@/db/schema/knox/approvals'; + +import { filterColumns } from '@/lib/filter-columns'; + +// --------------------------------------------- +// Types +// --------------------------------------------- + +export type ApprovalLine = typeof approvalLines.$inferSelect; + +export interface ApprovalLineWithUsage extends ApprovalLine { + templateCount: number; // 사용 중인 템플릿 수 +} + +// --------------------------------------------- +// List & read helpers +// --------------------------------------------- + +interface ListInput { + page: number; + perPage: number; + search?: string; + filters?: Record<string, unknown>[]; + joinOperator?: 'and' | 'or'; + sort?: Array<{ id: string; desc: boolean }>; +} + +export async function getApprovalLineList(input: ListInput) { + const offset = (input.page - 1) * input.perPage; + + /* ------------------------------------------------------------------ + * WHERE 절 구성 + * ----------------------------------------------------------------*/ + const advancedWhere = filterColumns({ + table: approvalLines, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filters: (input.filters ?? []) as any, + joinOperator: (input.joinOperator ?? 'and') as 'and' | 'or', + }); + + // 전역 검색 (name, description) + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(approvalLines.name, s), + ilike(approvalLines.description, s), + ); + } + + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + const where = + conditions.length === 0 + ? undefined + : conditions.length === 1 + ? conditions[0] + : and(...conditions); + + /* ------------------------------------------------------------------ + * ORDER BY 절 구성 + * ----------------------------------------------------------------*/ + let orderBy; + try { + orderBy = input.sort && input.sort.length > 0 + ? input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== 'string') return null; + if (!(item.id in approvalLines)) return null; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const col = approvalLines[item.id]; + return item.desc ? desc(col) : asc(col); + }) + .filter((v): v is Exclude<typeof v, null> => v !== null) + : [desc(approvalLines.updatedAt)]; + } catch { + orderBy = [desc(approvalLines.updatedAt)]; + } + + /* ------------------------------------------------------------------ + * 데이터 조회 + * ----------------------------------------------------------------*/ + const data = await db + .select() + .from(approvalLines) + .where(where) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + const totalResult = await db + .select({ count: count() }) + .from(approvalLines) + .where(where); + + const total = totalResult[0]?.count ?? 0; + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount, + }; +} + +// ---------------------------------------------------- +// Simple list for options (id, name) +// ---------------------------------------------------- +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function getApprovalLineOptions(category?: string): Promise<Array<{ id: string; name: string; aplns: any[]; category: string | null }>> { + const where = category + ? eq(approvalLines.category, category) + : undefined; + const rows = await db + .select({ id: approvalLines.id, name: approvalLines.name, aplns: approvalLines.aplns, category: approvalLines.category }) + .from(approvalLines) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .where(where as any) + .orderBy(asc(approvalLines.name)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return rows as Array<{ id: string; name: string; aplns: any[]; category: string | null }>; +} + +// ---------------------------------------------------- +// Distinct categories for filter +// ---------------------------------------------------- +export async function getApprovalLineCategories(): Promise<string[]> { + const rows = await db + .select({ category: approvalLines.category }) + .from(approvalLines) + .where(sql`${approvalLines.category} IS NOT NULL AND ${approvalLines.category} <> ''`) + .groupBy(approvalLines.category) + .orderBy(asc(approvalLines.category)); + return rows.map((r) => r.category!).filter(Boolean); +} + +// ---------------------------------------------------- +// Server Action for fetching options by category +// ---------------------------------------------------- +export async function getApprovalLineOptionsAction(category?: string) { + try { + const data = await getApprovalLineOptions(category) + return { success: true, data } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : '조회에 실패했습니다.' } + } +} + +// ---------------------------------------------------- +// Server Action for fetching distinct categories +// ---------------------------------------------------- +export async function getApprovalLineCategoriesAction() { + try { + const data = await getApprovalLineCategories() + return { success: true, data } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : '카테고리 조회에 실패했습니다.' } + } +} + +// ---------------------------------------------------- +// Get single approval line +// ---------------------------------------------------- +export async function getApprovalLine(id: string): Promise<ApprovalLine | null> { + const [line] = await db + .select() + .from(approvalLines) + .where(eq(approvalLines.id, id)) + .limit(1); + + return line || null; +} + +// ---------------------------------------------------- +// Create approval line +// ---------------------------------------------------- +interface CreateInput { + name: string; + description?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + aplns: any[]; // 결재선 구성 (JSON) + createdBy: number; +} + +export async function createApprovalLine(data: CreateInput): Promise<ApprovalLine> { + // 중복 이름 체크 + const existing = await db + .select({ id: approvalLines.id }) + .from(approvalLines) + .where(eq(approvalLines.name, data.name)) + .limit(1); + + if (existing.length > 0) { + throw new Error('이미 존재하는 결재선 이름입니다.'); + } + + const [newLine] = await db + .insert(approvalLines) + .values({ + name: data.name, + description: data.description, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + aplns: data.aplns as any, + createdBy: data.createdBy, + }) + .returning(); + + return newLine; +} + +// ---------------------------------------------------- +// Update approval line +// ---------------------------------------------------- +interface UpdateInput { + name?: string; + description?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + aplns?: any[]; // 결재선 구성 (JSON) + updatedBy: number; +} + +export async function updateApprovalLine(id: string, data: UpdateInput): Promise<ApprovalLine> { + const existing = await getApprovalLine(id); + if (!existing) throw new Error('결재선을 찾을 수 없습니다.'); + + // 이름 중복 체크 (자신 제외) + if (data.name && data.name !== existing.name) { + const duplicate = await db + .select({ id: approvalLines.id }) + .from(approvalLines) + .where( + and( + eq(approvalLines.name, data.name), + eq(approvalLines.id, id) + ) + ) + .limit(1); + + if (duplicate.length > 0) { + throw new Error('이미 존재하는 결재선 이름입니다.'); + } + } + + // 결재선 업데이트 + await db + .update(approvalLines) + .set({ + name: data.name ?? existing.name, + description: data.description ?? existing.description, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + aplns: (data.aplns as any) ?? (existing.aplns as any), + updatedAt: new Date(), + }) + .where(eq(approvalLines.id, id)); + + const result = await getApprovalLine(id); + if (!result) throw new Error('업데이트된 결재선을 조회할 수 없습니다.'); + return result; +} + +// ---------------------------------------------------- +// Server Actions +// ---------------------------------------------------- +export async function updateApprovalLineAction(id: string, data: UpdateInput) { + try { + const updated = await updateApprovalLine(id, data) + return { success: true, data: updated } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '업데이트에 실패했습니다.' + } + } +} + +// ---------------------------------------------------- +// Duplicate approval line +// ---------------------------------------------------- +export async function duplicateApprovalLine( + id: string, + newName: string, + createdBy: number, +): Promise<{ success: boolean; error?: string; data?: ApprovalLine }> { + try { + const existing = await getApprovalLine(id) + if (!existing) return { success: false, error: '결재선을 찾을 수 없습니다.' } + + // 새 결재선 생성 + const duplicated = await createApprovalLine({ + name: newName, + description: existing.description ? `${existing.description} (복사본)` : undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + aplns: existing.aplns as any, + createdBy, + }) + + return { success: true, data: duplicated } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : '복제에 실패했습니다.' } + } +} + +// ---------------------------------------------------- +// Delete (soft delete X -> 실제 삭제) +// ---------------------------------------------------- +export async function deleteApprovalLine(id: string): Promise<{ success: boolean; error?: string }> { + try { + await db.delete(approvalLines).where(eq(approvalLines.id, id)); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '삭제에 실패했습니다.', + }; + } +} + +// ---------------------------------------------------- +// Get approval line usage (템플릿에서 사용 중인지 확인) +// ---------------------------------------------------- +export async function getApprovalLineUsage(): Promise<{ templateCount: number }> { + // 현재는 approvalLines가 템플릿과 직접 연결되지 않으므로 0 반환 + // 추후 템플릿에서 결재선을 참조하는 구조로 변경 시 실제 사용량 계산 + return { templateCount: 0 }; +}
\ No newline at end of file diff --git a/lib/approval-line/table/approval-line-table-columns.tsx b/lib/approval-line/table/approval-line-table-columns.tsx new file mode 100644 index 00000000..5b35b92c --- /dev/null +++ b/lib/approval-line/table/approval-line-table-columns.tsx @@ -0,0 +1,197 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { type ApprovalLine } from "../service" +import { formatApprovalLine } from "../utils/format" +import { formatDate } from "@/lib/utils" +import { MoreHorizontal, Copy, Edit, Trash2 } from "lucide-react" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<{ + type: "update" | "delete" | "duplicate"; + row: { original: ApprovalLine }; + } | null>>; +} + + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ApprovalLine>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="결재선 이름" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[500px] truncate font-medium"> + {row.getValue("name")} + </span> + </div> + ) + }, + }, + { + accessorKey: "description", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="설명" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[500px] truncate"> + {row.getValue("description") || "-"} + </span> + </div> + ) + }, + }, + { + accessorKey: "aplns", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="결재선" /> + ), + cell: ({ row }) => { + const aplns = row.getValue("aplns") as unknown as Array<{ + seq: string; + name?: string; + emailAddress?: string; + role: string; + }>; + const approvalLineText = formatApprovalLine(aplns); + + return ( + <div className="flex space-x-2"> + <div className="flex flex-col gap-1"> + <div className="max-w-[400px] truncate text-sm"> + {approvalLineText} + <Badge variant="secondary" className="w-fit"> + {aplns?.length || 0}명 + </Badge> + </div> + </div> + </div> + ) + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="생성일" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[500px] truncate"> + {formatDate(row.getValue("createdAt"))} + </span> + </div> + ) + }, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="수정일" /> + ), + cell: ({ row }) => { + return ( + <div className="flex space-x-2"> + <span className="max-w-[500px] truncate"> + {formatDate(row.getValue("updatedAt"))} + </span> + </div> + ) + }, + }, + { + id: "actions", + cell: ({ row }) => { + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <MoreHorizontal className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onClick={() => { + setRowAction({ type: "update", row }); + }} + > + <Edit className="mr-2 size-4" aria-hidden="true" /> + 수정하기 + </DropdownMenuItem> + + <DropdownMenuItem + onClick={() => { + setRowAction({ type: "duplicate", row }); + }} + > + <Copy className="mr-2 size-4" aria-hidden="true" /> + 복제하기 + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onClick={() => { + setRowAction({ type: "delete", row }); + }} + className="text-destructive focus:text-destructive" + > + <Trash2 className="mr-2 size-4" aria-hidden="true" /> + 삭제하기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + enableSorting: false, + enableHiding: false, + size: 80, + }, + ] +}
\ No newline at end of file diff --git a/lib/approval-line/table/approval-line-table-toolbar-actions.tsx b/lib/approval-line/table/approval-line-table-toolbar-actions.tsx new file mode 100644 index 00000000..6b6600fe --- /dev/null +++ b/lib/approval-line/table/approval-line-table-toolbar-actions.tsx @@ -0,0 +1,73 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { DataTableViewOptions } from "@/components/data-table/data-table-view-options" +import { Plus, Download, Upload } from "lucide-react" +import { type ApprovalLine } from "../service" + +interface ApprovalLineTableToolbarActionsProps { + table: Table<ApprovalLine> + onCreateLine: () => void +} + +export function ApprovalLineTableToolbarActions({ + table, + onCreateLine, +}: ApprovalLineTableToolbarActionsProps) { + const isFiltered = table.getState().columnFilters.length > 0 + + return ( + <div className="flex items-center justify-between"> + <div className="flex flex-1 items-center space-x-2"> + <Input + placeholder="결재선 검색..." + value={(table.getColumn("name")?.getFilterValue() as string) ?? ""} + onChange={(event) => + table.getColumn("name")?.setFilterValue(event.target.value) + } + className="h-8 w-[150px] lg:w-[250px]" + /> + {isFiltered && ( + <Button + variant="ghost" + onClick={() => table.resetColumnFilters()} + className="h-8 px-2 lg:px-3" + > + 초기화 + </Button> + )} + </div> + <div className="flex items-center space-x-2"> + <Button + variant="outline" + size="sm" + className="ml-auto hidden h-8 lg:flex" + > + <Upload className="mr-2 h-4 w-4" /> + 가져오기 + </Button> + <Button + variant="outline" + size="sm" + className="ml-auto hidden h-8 lg:flex" + > + <Download className="mr-2 h-4 w-4" /> + 내보내기 + </Button> + <DataTableViewOptions table={table} /> + <Button + variant="outline" + size="sm" + className="ml-auto h-8" + onClick={onCreateLine} + > + <Plus className="mr-2 h-4 w-4" /> + 결재선 생성 + </Button> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/approval-line/table/approval-line-table.tsx b/lib/approval-line/table/approval-line-table.tsx new file mode 100644 index 00000000..21b9972c --- /dev/null +++ b/lib/approval-line/table/approval-line-table.tsx @@ -0,0 +1,130 @@ +"use client"; + +import * as React from 'react'; +import { useDataTable } from '@/hooks/use-data-table'; +import { DataTable } from '@/components/data-table/data-table'; +import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from '@/types/table'; + +import { getColumns } from './approval-line-table-columns'; +import { getApprovalLineList } from '../service'; +import { type ApprovalLine } from '../service'; +import { ApprovalLineTableToolbarActions } from './approval-line-table-toolbar-actions'; +import { CreateApprovalLineSheet } from './create-approval-line-sheet'; +import { UpdateApprovalLineSheet } from './update-approval-line-sheet'; +import { DuplicateApprovalLineSheet } from './duplicate-approval-line-sheet'; +import { DeleteApprovalLineDialog } from './delete-approval-line-dialog'; + +interface ApprovalLineTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getApprovalLineList>>, + ]>; +} + +export function ApprovalLineTable({ promises }: ApprovalLineTableProps) { + const [{ data, pageCount }] = React.use(promises); + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ApprovalLine> | null>(null); + + const [showCreateSheet, setShowCreateSheet] = React.useState(false); + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ); + + // 기본 & 고급 필터 필드 + const filterFields: DataTableFilterField<ApprovalLine>[] = []; + const advancedFilterFields: DataTableAdvancedFilterField<ApprovalLine>[] = [ + { + id: 'name', + label: '결재선 이름', + type: 'text', + }, + { + id: 'description', + label: '설명', + type: 'text', + }, + { + id: 'createdAt', + label: '생성일', + type: 'date', + }, + { + id: 'updatedAt', + label: '수정일', + type: 'date', + }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'updatedAt', desc: true }], + columnPinning: { right: ['actions'] }, + }, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }); + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <ApprovalLineTableToolbarActions + table={table} + onCreateLine={() => setShowCreateSheet(true)} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 새 결재선 생성 Sheet */} + <CreateApprovalLineSheet + open={showCreateSheet} + onOpenChange={setShowCreateSheet} + /> + + {/* 결재선 수정 Sheet */} + <UpdateApprovalLineSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + line={rowAction?.type === "update" ? rowAction.row.original : null} + /> + + {/* 결재선 복제 Sheet */} + <DuplicateApprovalLineSheet + open={rowAction?.type === "duplicate"} + onOpenChange={() => setRowAction(null)} + line={rowAction?.type === "duplicate" ? rowAction.row.original : null} + /> + + {/* 결재선 삭제 Dialog */} + <DeleteApprovalLineDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + lines={rowAction?.type === "delete" ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null) + // 테이블 새로고침은 server action에서 자동으로 처리됨 + }} + /> + </> + ); +}
\ No newline at end of file diff --git a/lib/approval-line/table/create-approval-line-sheet.tsx b/lib/approval-line/table/create-approval-line-sheet.tsx new file mode 100644 index 00000000..fdc8cc64 --- /dev/null +++ b/lib/approval-line/table/create-approval-line-sheet.tsx @@ -0,0 +1,224 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Separator } from "@/components/ui/separator" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" +import { createApprovalLine } from "../service" +import { type ApprovalLineFormData, ApprovalLineSchema } from "../validations" +import { ApprovalLineSelector } from "@/components/knox/approval/ApprovalLineSelector" +import { OrganizationManagerSelector, type OrganizationManagerItem } from "@/components/common/organization/organization-manager-selector" +import { useSession } from "next-auth/react" + +interface CreateApprovalLineSheetProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CreateApprovalLineSheet({ open, onOpenChange }: CreateApprovalLineSheetProps) { + const { data: session } = useSession(); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 고유 ID 생성 함수 (조직 관리자 추가 시 사용) + const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + + const form = useForm<ApprovalLineFormData>({ + resolver: zodResolver(ApprovalLineSchema), + defaultValues: { + name: "", + description: "", + aplns: [ + // 기안자는 항상 첫 번째로 고정 (플레이스홀더) + { + id: generateUniqueId(), + epId: undefined, + userId: undefined, + emailAddress: undefined, + name: "기안자", + deptName: undefined, + role: "0", + seq: "0", + opinion: "", + }, + ], + }, + }); + + const aplns = form.watch("aplns"); + + // 조직 관리자 추가 (공용 선택기 외 보조 입력 경로) + const addOrganizationManagers = (managers: OrganizationManagerItem[]) => { + const next = [...aplns]; + const uniqueSeqs = Array.from(new Set(next.map((a) => parseInt(a.seq)))); + const maxSeq = uniqueSeqs.length ? Math.max(...uniqueSeqs) : 0; + + managers.forEach((manager, idx) => { + const exists = next.findIndex((a) => a.epId === manager.managerId); + if (exists === -1) { + const newSeqNum = Math.max(1, maxSeq + 1 + idx); + const newSeq = newSeqNum.toString(); + next.push({ + id: generateUniqueId(), + epId: manager.managerId, + userId: undefined, + emailAddress: undefined, + name: manager.managerName, + deptName: manager.departmentName, + role: "1", + seq: newSeq, + opinion: "", + }); + } + }); + + form.setValue("aplns", next, { shouldDirty: true }); + }; + + const onSubmit = async (data: ApprovalLineFormData) => { + setIsSubmitting(true); + try { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다."); + return; + } + + await createApprovalLine({ + name: data.name, + description: data.description, + aplns: data.aplns, + createdBy: Number(session.user.id), + }); + + toast.success("결재선이 성공적으로 생성되었습니다."); + form.reset(); + onOpenChange(false); + } catch { + toast.error("결재선 생성 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-full sm:max-w-4xl overflow-y-auto"> + <SheetHeader> + <SheetTitle>결재선 생성</SheetTitle> + <SheetDescription> + 새로운 결재선을 생성합니다. 결재자를 추가하고 순서를 조정할 수 있습니다. + </SheetDescription> + </SheetHeader> + + <div className="mt-6"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 기본 정보 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>결재선 이름 *</FormLabel> + <FormControl> + <Input placeholder="결재선 이름을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea placeholder="결재선에 대한 설명을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <Separator /> + + {/* 결재 경로 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">결재 경로</h3> + + <ApprovalLineSelector + value={aplns} + onChange={(next) => form.setValue("aplns", next, { shouldDirty: true })} + placeholder="결재자를 검색하세요..." + domainFilter={{ type: "exclude", domains: ["partners"] }} + maxSelections={10} + /> + + {/* 조직 관리자 추가 (선택 사항) */} + {/* <div className="p-4 border border-dashed border-gray-300 rounded-lg"> + <div className="mb-2"> + <label className="text-sm font-medium text-gray-700">조직 관리자로 추가</label> + <p className="text-xs text-gray-500">조직별 책임자를 검색하여 추가하세요</p> + </div> + <OrganizationManagerSelector + selectedManagers={[]} + onManagersChange={addOrganizationManagers} + placeholder="조직 관리자를 검색하세요..." + maxSelections={10} + /> + </div> */} + </div> + + <Separator /> + + {/* 제출 버튼 */} + <div className="flex justify-end space-x-3"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 생성 중... + </> + ) : ( + "결재선 생성" + )} + </Button> + </div> + </form> + </Form> + </div> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/approval-line/table/delete-approval-line-dialog.tsx b/lib/approval-line/table/delete-approval-line-dialog.tsx new file mode 100644 index 00000000..aa1d8949 --- /dev/null +++ b/lib/approval-line/table/delete-approval-line-dialog.tsx @@ -0,0 +1,168 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" +import { Loader2, Trash2, AlertTriangle } from "lucide-react" +import { deleteApprovalLine, getApprovalLineUsage } from "../service" +import { type ApprovalLine } from "../service" + +interface DeleteApprovalLineDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + lines: ApprovalLine[] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteApprovalLineDialog({ + open, + onOpenChange, + lines, + showTrigger = true, + onSuccess, +}: DeleteApprovalLineDialogProps) { + const [isDeleting, setIsDeleting] = React.useState(false) + const [usageInfo, setUsageInfo] = React.useState<{ templateCount: number } | null>(null) + + // 사용량 정보 조회 + React.useEffect(() => { + if (open && lines.length === 1) { + getApprovalLineUsage(lines[0].id).then(setUsageInfo) + } + }, [open, lines]) + + const handleDelete = async () => { + setIsDeleting(true) + + try { + const deletePromises = lines.map((line) => deleteApprovalLine(line.id)) + const results = await Promise.all(deletePromises) + + const successCount = results.filter((r) => r.success).length + const errorCount = results.length - successCount + + if (successCount > 0) { + toast.success(`${successCount}개의 결재선이 삭제되었습니다.`) + onSuccess?.() + onOpenChange(false) + } + + if (errorCount > 0) { + toast.error(`${errorCount}개의 결재선 삭제에 실패했습니다.`) + } + } catch (error) { + toast.error("삭제 중 오류가 발생했습니다.") + } finally { + setIsDeleting(false) + } + } + + const isSingleLine = lines.length === 1 + const line = isSingleLine ? lines[0] : null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Trash2 className="h-5 w-5 text-destructive" /> + 결재선 삭제 + </DialogTitle> + <DialogDescription> + {isSingleLine + ? `"${line?.name}" 결재선을 삭제하시겠습니까?` + : `${lines.length}개의 결재선을 삭제하시겠습니까?`} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 사용량 정보 표시 */} + {isSingleLine && usageInfo && ( + <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg"> + <div className="flex items-center gap-2 text-yellow-700"> + <AlertTriangle className="w-4 h-4" /> + <span className="font-medium">사용 중인 결재선</span> + </div> + <p className="text-sm text-yellow-600 mt-1"> + 이 결재선은 {usageInfo.templateCount}개의 템플릿에서 사용되고 있습니다. + </p> + </div> + )} + + {/* 삭제할 결재선 목록 */} + <div className="space-y-2"> + <h4 className="text-sm font-medium">삭제할 결재선:</h4> + <div className="space-y-2 max-h-40 overflow-y-auto"> + {lines.map((line) => ( + <div + key={line.id} + className="flex items-center justify-between p-3 border rounded-lg" + > + <div className="flex-1"> + <div className="font-medium">{line.name}</div> + {line.description && ( + <div className="text-sm text-gray-500"> + {line.description} + </div> + )} + <div className="text-xs text-gray-400 mt-1"> + 결재자 {(line.aplns as any[])?.length || 0}명 + </div> + </div> + </div> + ))} + </div> + </div> + + {/* 경고 메시지 */} + <div className="p-4 bg-red-50 border border-red-200 rounded-lg"> + <div className="flex items-center gap-2 text-red-700"> + <AlertTriangle className="w-4 h-4" /> + <span className="font-medium">주의</span> + </div> + <p className="text-sm text-red-600 mt-1"> + 삭제된 결재선은 복구할 수 없습니다. 이 작업은 되돌릴 수 없습니다. + </p> + </div> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isDeleting} + > + 취소 + </Button> + <Button + variant="destructive" + onClick={handleDelete} + disabled={isDeleting} + > + {isDeleting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 삭제 중... + </> + ) : ( + <> + <Trash2 className="w-4 h-4 mr-2" /> + 삭제 + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/approval-line/table/duplicate-approval-line-sheet.tsx b/lib/approval-line/table/duplicate-approval-line-sheet.tsx new file mode 100644 index 00000000..0bb3ab2c --- /dev/null +++ b/lib/approval-line/table/duplicate-approval-line-sheet.tsx @@ -0,0 +1,206 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { toast } from "sonner" +import { Loader2, Copy } from "lucide-react" +import { duplicateApprovalLine } from "../service" +import { type ApprovalLine } from "../service" +import { useSession } from "next-auth/react" + +const duplicateSchema = z.object({ + name: z.string().min(1, "결재선 이름은 필수입니다"), + description: z.string().optional(), +}) + +type DuplicateFormData = z.infer<typeof duplicateSchema> + +interface DuplicateApprovalLineSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + line: ApprovalLine | null +} + +export function DuplicateApprovalLineSheet({ + open, + onOpenChange, + line, +}: DuplicateApprovalLineSheetProps) { + const { data: session } = useSession() + const [isSubmitting, setIsSubmitting] = React.useState(false) + + const form = useForm<DuplicateFormData>({ + resolver: zodResolver(duplicateSchema), + defaultValues: { + name: line ? `${line.name} (복사본)` : "", + description: line?.description ? `${line.description} (복사본)` : "", + }, + }) + + // line이 변경될 때 폼 초기화 + React.useEffect(() => { + if (line) { + form.reset({ + name: `${line.name} (복사본)`, + description: line.description ? `${line.description} (복사본)` : "", + }) + } + }, [line, form]) + + const onSubmit = async (data: DuplicateFormData) => { + if (!line || !session?.user?.id) { + toast.error("복제할 결재선이 없거나 로그인이 필요합니다.") + return + } + + setIsSubmitting(true) + + try { + const result = await duplicateApprovalLine( + line.id, + data.name, + session.user.id + ) + + if (result.success) { + toast.success("결재선이 성공적으로 복제되었습니다.") + form.reset() + onOpenChange(false) + } else { + toast.error(result.error || "복제에 실패했습니다.") + } + } catch (error) { + toast.error("복제 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + if (!line) return null + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent> + <SheetHeader> + <SheetTitle className="flex items-center gap-2"> + <Copy className="h-5 w-5" /> + 결재선 복제 + </SheetTitle> + <SheetDescription> + "{line.name}" 결재선을 복제하여 새로운 결재선을 만듭니다. + </SheetDescription> + </SheetHeader> + + <div className="mt-6"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 원본 정보 표시 */} + <div className="p-4 bg-gray-50 border rounded-lg"> + <h4 className="font-medium text-sm mb-2">원본 결재선 정보</h4> + <div className="space-y-2 text-sm"> + <div> + <span className="font-medium">이름:</span> {line.name} + </div> + {line.description && ( + <div> + <span className="font-medium">설명:</span> {line.description} + </div> + )} + <div> + <span className="font-medium">결재자:</span> {(line.aplns as any[])?.length || 0}명 + </div> + </div> + </div> + + {/* 새 결재선 정보 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>새 결재선 이름 *</FormLabel> + <FormControl> + <Input placeholder="새 결재선 이름을 입력하세요" {...field} /> + </FormControl> + <FormDescription> + 원본과 구분할 수 있는 이름을 입력하세요. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="새 결재선에 대한 설명을 입력하세요" + {...field} + /> + </FormControl> + <FormDescription> + 선택사항입니다. 결재선의 용도나 특징을 설명할 수 있습니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 제출 버튼 */} + <div className="flex justify-end space-x-3"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 복제 중... + </> + ) : ( + <> + <Copy className="w-4 h-4 mr-2" /> + 결재선 복제 + </> + )} + </Button> + </div> + </form> + </Form> + </div> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/approval-line/table/update-approval-line-sheet.tsx b/lib/approval-line/table/update-approval-line-sheet.tsx new file mode 100644 index 00000000..efc720de --- /dev/null +++ b/lib/approval-line/table/update-approval-line-sheet.tsx @@ -0,0 +1,264 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Separator } from "@/components/ui/separator" +import { toast } from "sonner" +import { Loader2, Edit } from "lucide-react" +import { updateApprovalLine, type ApprovalLine } from "../service" +import { type ApprovalLineFormData, ApprovalLineSchema } from "../validations" +import { OrganizationManagerSelector, type OrganizationManagerItem } from "@/components/common/organization/organization-manager-selector" +import { useSession } from "next-auth/react" +import { ApprovalLineSelector } from "@/components/knox/approval/ApprovalLineSelector" + +interface UpdateApprovalLineSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + line: ApprovalLine | null +} + +// 최소 형태의 Apln 아이템 타입 (line.aplns JSON 구조 대응) +interface MinimalAplnItem { + id: string + epId?: string + userId?: string + emailAddress?: string + name?: string + deptName?: string + role: "0" | "1" | "2" | "3" | "4" | "7" | "9" + seq: string + opinion?: string + [key: string]: unknown +} + +export function UpdateApprovalLineSheet({ open, onOpenChange, line }: UpdateApprovalLineSheetProps) { + const { data: session } = useSession(); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + // 고유 ID 생성 함수 (조직 관리자 추가 시 사용) + const generateUniqueId = () => `apln-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + + const form = useForm<ApprovalLineFormData>({ + resolver: zodResolver(ApprovalLineSchema), + defaultValues: { + name: "", + description: "", + aplns: [], + }, + }); + + // line이 변경될 때 폼 초기화 + React.useEffect(() => { + if (line) { + const existingAplns = (line.aplns as unknown as MinimalAplnItem[]) || []; + + // 기안자가 없으면 추가 + const hasDraft = existingAplns.some((a) => String(a.seq) === "0"); + let nextAplns: MinimalAplnItem[] = existingAplns; + + if (!hasDraft) { + nextAplns = [ + { + id: generateUniqueId(), + epId: undefined, + userId: undefined, + emailAddress: undefined, + name: "기안자", + deptName: undefined, + role: "0", + seq: "0", + opinion: "", + }, + ...existingAplns, + ]; + } + + form.reset({ + name: line.name, + description: line.description || "", + aplns: nextAplns as ApprovalLineFormData["aplns"], + }); + } + }, [line, form]); + + const aplns = form.watch("aplns"); + + // 조직 관리자 추가 (공용 선택기 외 보조 입력 경로) + const addOrganizationManagers = (managers: OrganizationManagerItem[]) => { + const next = [...aplns]; + const uniqueSeqs = Array.from(new Set(next.map((a) => parseInt(a.seq)))); + const maxSeq = uniqueSeqs.length ? Math.max(...uniqueSeqs) : 0; + + managers.forEach((manager, idx) => { + const exists = next.findIndex((a) => a.epId === manager.managerId); + if (exists === -1) { + const newSeqNum = Math.max(1, maxSeq + 1 + idx); + const newSeq = newSeqNum.toString(); + next.push({ + id: generateUniqueId(), + epId: manager.managerId, + userId: undefined, + emailAddress: undefined, + name: manager.managerName, + deptName: manager.departmentName, + role: "1", + seq: newSeq, + opinion: "", + }); + } + }); + + form.setValue("aplns", next, { shouldDirty: true }); + }; + + const onSubmit = async (data: ApprovalLineFormData) => { + if (!line || !session?.user?.id) { + toast.error("수정할 결재선이 없거나 로그인이 필요합니다."); + return; + } + + setIsSubmitting(true); + try { + await updateApprovalLine(line.id, { + name: data.name, + description: data.description, + aplns: data.aplns, + updatedBy: Number(session.user.id), + }); + + toast.success("결재선이 성공적으로 수정되었습니다."); + onOpenChange(false); + } catch { + toast.error("결재선 수정 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + if (!line) return null; + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-full sm:max-w-4xl overflow-y-auto"> + <SheetHeader> + <SheetTitle className="flex items-center gap-2"> + <Edit className="h-5 w-5" /> + 결재선 수정 + </SheetTitle> + <SheetDescription> + "{line.name}" 결재선을 수정합니다. 결재자를 추가하고 순서를 조정할 수 있습니다. + </SheetDescription> + </SheetHeader> + + <div className="mt-6"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 기본 정보 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>결재선 이름 *</FormLabel> + <FormControl> + <Input placeholder="결재선 이름을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea placeholder="결재선에 대한 설명을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <Separator /> + + {/* 결재 경로 */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold">결재 경로</h3> + + <ApprovalLineSelector + value={aplns} + onChange={(next) => form.setValue("aplns", next, { shouldDirty: true })} + placeholder="결재자를 검색하세요..." + domainFilter={{ type: "exclude", domains: ["partners"] }} + maxSelections={10} + /> + + {/* 조직 관리자 추가 (선택 사항) */} + {/* <div className="p-4 border border-dashed border-gray-300 rounded-lg"> + <div className="mb-2"> + <label className="text-sm font-medium text-gray-700">조직 관리자로 추가</label> + <p className="text-xs text-gray-500">조직별 책임자를 검색하여 추가하세요</p> + </div> + <OrganizationManagerSelector + selectedManagers={[]} + onManagersChange={addOrganizationManagers} + placeholder="조직 관리자를 검색하세요..." + maxSelections={10} + /> + </div> */} + </div> + + <Separator /> + + {/* 제출 버튼 */} + <div className="flex justify-end space-x-3"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 수정 중... + </> + ) : ( + "결재선 수정" + )} + </Button> + </div> + </form> + </Form> + </div> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/approval-line/utils/format.ts b/lib/approval-line/utils/format.ts new file mode 100644 index 00000000..d74c7d1e --- /dev/null +++ b/lib/approval-line/utils/format.ts @@ -0,0 +1,53 @@ +export interface SimpleApln { + seq: string + name?: string + emailAddress?: string + role: string +} + +export function formatApprovalLine(aplns: SimpleApln[]): string { + if (!aplns || aplns.length === 0) return '결재자 없음' + + const roleMap: Record<string, string> = { + '0': '기안', + '1': '결재', + '2': '합의', + '3': '후결', + '4': '병렬합의', + '7': '병렬결재', + '9': '통보', + } + + const groupedBySeq = aplns.reduce<Record<string, SimpleApln[]>>((groups, apln) => { + const seq = apln.seq + if (!groups[seq]) groups[seq] = [] + groups[seq].push(apln) + return groups + }, {}) + + const sortedSeqs = Object.keys(groupedBySeq).sort((a, b) => parseInt(a) - parseInt(b)) + + return sortedSeqs + .map((seq) => { + const group = groupedBySeq[seq] + const isParallel = group.length > 1 || group.some((apln) => apln.role === '4' || apln.role === '7') + if (isParallel) { + const parallelMembers = group + .map((apln) => { + const name = apln.name || apln.emailAddress || '이름없음' + const role = roleMap[apln.role] || '알수없음' + return `${name}(${role})` + }) + .join(', ') + return `[${parallelMembers}]` + } else { + const apln = group[0] + const name = apln.name || apln.emailAddress || '이름없음' + const role = roleMap[apln.role] || '알수없음' + return `${name}(${role})` + } + }) + .join(' → ') +} + + diff --git a/lib/approval-line/validations.ts b/lib/approval-line/validations.ts new file mode 100644 index 00000000..4f55454a --- /dev/null +++ b/lib/approval-line/validations.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { SearchParamsApprovalTemplateCache } from '@/lib/approval-template/validations'; + +// 결재선 검색 파라미터 스키마 (기존 템플릿 검증 스키마 재사용) +export const SearchParamsApprovalLineCache = SearchParamsApprovalTemplateCache; + +// 결재선 생성/수정 스키마 +export const ApprovalLineSchema = z.object({ + name: z.string().min(1, '결재선 이름은 필수입니다'), + description: z.string().optional(), + aplns: z.array(z.object({ + id: z.string(), + epId: z.string().optional(), + userId: z.string().optional(), + emailAddress: z.string().email('유효한 이메일 주소를 입력해주세요').optional(), + name: z.string().optional(), + deptName: z.string().optional(), + role: z.enum(['0', '1', '2', '3', '4', '7', '9']), + seq: z.string(), + opinion: z.string().optional() + })).min(1, '최소 1개의 결재 경로가 필요합니다'), +}); + +export type ApprovalLineFormData = z.infer<typeof ApprovalLineSchema>;
\ No newline at end of file diff --git a/lib/approval-template/editor/approval-template-editor.tsx b/lib/approval-template/editor/approval-template-editor.tsx new file mode 100644 index 00000000..f23ac4bd --- /dev/null +++ b/lib/approval-template/editor/approval-template-editor.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { Loader, Save, ArrowLeft } from "lucide-react" +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import RichTextEditor from "@/components/rich-text-editor/RichTextEditor" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { formatApprovalLine } from "@/lib/approval-line/utils/format" +import { getApprovalLineOptionsAction } from "@/lib/approval-line/service" + +import { type ApprovalTemplate } from "@/lib/approval-template/service" +import { type Editor } from "@tiptap/react" +import { updateApprovalTemplateAction } from "@/lib/approval-template/service" +import { useSession } from "next-auth/react" + +interface ApprovalTemplateEditorProps { + templateId: string + initialTemplate: ApprovalTemplate + staticVariables?: Array<{ variableName: string }> + approvalLineOptions: Array<{ id: string; name: string }> + approvalLineCategories: string[] +} + +export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVariables = [], approvalLineOptions, approvalLineCategories }: ApprovalTemplateEditorProps) { + const { data: session } = useSession() + const [rte, setRte] = React.useState<Editor | null>(null) + const [template, setTemplate] = React.useState(initialTemplate) + const [isSaving, startSaving] = React.useTransition() + + // 편집기에 전달할 변수 목록 + // 템플릿(DB) 변수 + 정적(config) 변수 병합 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dbVariables: string[] = Array.isArray((template as any).variables) + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + (template as any).variables.map((v: any) => v.variableName) + : [] + + const mergedVariables = Array.from(new Set([...dbVariables, ...staticVariables.map((v) => v.variableName)])) + + const variableOptions = mergedVariables.map((name) => ({ label: name, html: `{{${name}}}` })) + + const [form, setForm] = React.useState({ + name: template.name, + subject: template.subject, + content: template.content, + description: template.description ?? "", + category: template.category ?? "", + approvalLineId: (template as { approvalLineId?: string | null }).approvalLineId ?? "", + }) + + const [category, setCategory] = React.useState(form.category ?? (approvalLineCategories[0] ?? "")) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [lineOptions, setLineOptions] = React.useState(approvalLineOptions as Array<{ id: string; name: string; aplns?: any[]; category?: string | null }>) + const [isLoadingLines, setIsLoadingLines] = React.useState(false) + + React.useEffect(() => { + let active = true + const run = async () => { + setIsLoadingLines(true) + const { success, data } = await getApprovalLineOptionsAction(category || undefined) + if (active) { + setIsLoadingLines(false) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (success && data) setLineOptions(data as any) + // 카테고리 바뀌면 결재선 선택 초기화 + setForm((prev) => ({ ...prev, category, approvalLineId: "" })) + } + } + run() + return () => { + active = false + } + }, [category]) + + function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) { + const { name, value } = e.target + setForm((prev) => ({ ...prev, [name]: value })) + } + + function handleSave() { + startSaving(async () => { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + const { success, error, data } = await updateApprovalTemplateAction(templateId, { + name: form.name, + subject: form.subject, + content: form.content, + description: form.description, + category: form.category || undefined, + approvalLineId: form.approvalLineId ? form.approvalLineId : null, + updatedBy: Number(session.user.id), + }) + + if (!success || error || !data) { + toast.error(error ?? "저장에 실패했습니다") + return + } + + setTemplate(data) + toast.success("저장되었습니다") + }) + } + + return ( + <div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8"> + {/* Header */} + <div className="flex items-center gap-4"> + <Button variant="ghost" size="icon" asChild> + <Link href="/evcp/approval/template"> + <ArrowLeft className="h-4 w-4" /> + </Link> + </Button> + <div className="flex-1"> + <div className="flex items-center gap-3"> + <h1 className="text-2xl font-semibold">{template.name}</h1> + <Badge variant="outline" className="text-xs"> + 최근 수정: {new Date(template.updatedAt).toLocaleDateString("ko-KR")} + </Badge> + {template.category && ( + <Badge variant="secondary" className="text-xs">{template.category}</Badge> + )} + </div> + <p className="text-sm text-muted-foreground">{template.description || "결재 템플릿 편집"}</p> + </div> + <Button onClick={handleSave} disabled={isSaving}> + {isSaving && <Loader className="mr-2 h-4 w-4 animate-spin" />} + <Save className="mr-2 h-4 w-4" /> 저장 + </Button> + </div> + + <Separator /> + + <Tabs defaultValue="editor" className="flex-1"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="editor">편집</TabsTrigger> + <TabsTrigger value="preview">미리보기</TabsTrigger> + </TabsList> + + <TabsContent value="editor" className="mt-4 flex flex-col gap-4"> + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-2"> + <label className="text-sm font-medium">이름</label> + <Input name="name" value={form.name} onChange={handleChange} /> + </div> + <div className="space-y-2"> + <label className="text-sm font-medium">카테고리</label> + <Select + value={category} + onValueChange={(value) => setCategory(value)} + > + <SelectTrigger> + <SelectValue placeholder="카테고리를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {approvalLineCategories.map((cat) => ( + <SelectItem key={cat} value={cat}> + {cat} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + <div className="space-y-2"> + <label className="text-sm font-medium">설명 (선택)</label> + <Input name="description" value={form.description} onChange={handleChange} /> + </div> + <div className="space-y-2"> + <label className="text-sm font-medium">제목</label> + <Input name="subject" value={form.subject} onChange={handleChange} /> + </div> + <div className="space-y-2"> + <label className="text-sm font-medium">결재선</label> + <Select + value={form.approvalLineId} + onValueChange={(value) => setForm((prev) => ({ ...prev, approvalLineId: value }))} + disabled={!category || isLoadingLines} + > + <SelectTrigger> + <SelectValue placeholder={category ? (isLoadingLines ? "불러오는 중..." : "결재선을 선택하세요") : "카테고리를 먼저 선택하세요"} /> + </SelectTrigger> + <SelectContent> + {lineOptions.map((opt) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const label = opt.aplns ? `${opt.name} — ${formatApprovalLine(opt.aplns as any)}` : opt.name + return ( + <SelectItem key={opt.id} value={opt.id}> + {label} + </SelectItem> + ) + })} + </SelectContent> + </Select> + </div> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle>HTML 내용</CardTitle> + <CardDescription>표, 이미지, 변수 등을 자유롭게 편집하세요.</CardDescription> + </CardHeader> + <CardContent> + {variableOptions.length > 0 && ( + <div className="mb-4 flex flex-wrap gap-2"> + {variableOptions.map((v: { label: string; html: string }) => ( + <Button + key={v.label} + variant="outline" + size="sm" + onClick={() => rte?.chain().focus().insertContent(v.html).run()} + > + {`{{${v.label}}}`} + </Button> + ))} + </div> + )} + <RichTextEditor + value={form.content} + onChange={(val) => setForm((prev) => ({ ...prev, content: val }))} + onReady={(editor) => setRte(editor)} + height="400px" + /> + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="preview" className="mt-4"> + <Card> + <CardHeader> + <CardTitle>미리보기</CardTitle> + </CardHeader> + <CardContent className="border rounded-md p-4 overflow-auto bg-background"> + <div dangerouslySetInnerHTML={{ __html: form.content }} /> + </CardContent> + </Card> + </TabsContent> + </Tabs> + </div> + ) +} diff --git a/lib/approval-template/service.ts b/lib/approval-template/service.ts new file mode 100644 index 00000000..5dc989d9 --- /dev/null +++ b/lib/approval-template/service.ts @@ -0,0 +1,330 @@ +'use server'; + +import db from '@/db/db'; +import { + and, + asc, + count, + desc, + eq, + ilike, + or, +} from 'drizzle-orm'; + +import { + approvalTemplates, + approvalTemplateVariables, + approvalTemplateHistory, +} from '@/db/schema/knox/approvals'; + +import { filterColumns } from '@/lib/filter-columns'; + +// --------------------------------------------- +// Types +// --------------------------------------------- + +export type ApprovalTemplate = typeof approvalTemplates.$inferSelect; +export type ApprovalTemplateVariable = + typeof approvalTemplateVariables.$inferSelect; +export type ApprovalTemplateHistory = + typeof approvalTemplateHistory.$inferSelect; + +export interface ApprovalTemplateWithVariables extends ApprovalTemplate { + variables: ApprovalTemplateVariable[]; +} + +// --------------------------------------------- +// List & read helpers +// --------------------------------------------- + +interface ListInput { + page: number; + perPage: number; + search?: string; + filters?: Record<string, unknown>[]; + joinOperator?: 'and' | 'or'; + sort?: Array<{ id: string; desc: boolean }>; +} + +export async function getApprovalTemplateList(input: ListInput) { + const offset = (input.page - 1) * input.perPage; + + /* ------------------------------------------------------------------ + * WHERE 절 구성 + * ----------------------------------------------------------------*/ + const advancedWhere = filterColumns({ + table: approvalTemplates, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 (name, subject, description, category) + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(approvalTemplates.name, s), + ilike(approvalTemplates.subject, s), + ilike(approvalTemplates.description, s), + ilike(approvalTemplates.category, s), + ); + } + + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + const where = + conditions.length === 0 + ? undefined + : conditions.length === 1 + ? conditions[0] + : and(...conditions); + + /* ------------------------------------------------------------------ + * ORDER BY 절 구성 + * ----------------------------------------------------------------*/ + let orderBy; + try { + orderBy = input.sort && input.sort.length > 0 + ? input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== 'string') return null; + if (!(item.id in approvalTemplates)) return null; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const col = approvalTemplates[item.id]; + return item.desc ? desc(col) : asc(col); + }) + .filter((v): v is Exclude<typeof v, null> => v !== null) + : [desc(approvalTemplates.updatedAt)]; + } catch { + orderBy = [desc(approvalTemplates.updatedAt)]; + } + + /* ------------------------------------------------------------------ + * 데이터 조회 + * ----------------------------------------------------------------*/ + const data = await db + .select() + .from(approvalTemplates) + .where(where) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + const totalResult = await db + .select({ count: count() }) + .from(approvalTemplates) + .where(where); + + const total = totalResult[0]?.count ?? 0; + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount, + }; +} + +// ---------------------------------------------------- +// Get single template + variables +// ---------------------------------------------------- +export async function getApprovalTemplate(id: string): Promise<ApprovalTemplateWithVariables | null> { + const [template] = await db + .select() + .from(approvalTemplates) + .where(eq(approvalTemplates.id, id)) + .limit(1); + + if (!template) return null; + + const variables = await db + .select() + .from(approvalTemplateVariables) + .where(eq(approvalTemplateVariables.approvalTemplateId, id)) + .orderBy(approvalTemplateVariables.variableName); + + return { + ...template, + variables, + }; +} + +// ---------------------------------------------------- +// Create template +// ---------------------------------------------------- +interface CreateInput { + name: string; + subject: string; + content: string; + category?: string; + description?: string; + createdBy: number; + approvalLineId?: string | null; + variables?: Array<{ + variableName: string; + variableType: string; + defaultValue?: string; + description?: string; + }>; +} + +export async function createApprovalTemplate(data: CreateInput): Promise<ApprovalTemplateWithVariables> { + // 중복 이름 체크 (옵션) + const existing = await db + .select({ id: approvalTemplates.id }) + .from(approvalTemplates) + .where(eq(approvalTemplates.name, data.name)) + .limit(1); + + if (existing.length > 0) { + throw new Error('이미 존재하는 템플릿 이름입니다.'); + } + + const [newTemplate] = await db + .insert(approvalTemplates) + .values({ + name: data.name, + subject: data.subject, + content: data.content, + category: data.category, + description: data.description, + approvalLineId: data.approvalLineId ?? null, + createdBy: data.createdBy, + }) + .returning(); + + if (data.variables?.length) { + const variableRows = data.variables.map((v) => ({ + approvalTemplateId: newTemplate.id, + variableName: v.variableName, + variableType: v.variableType, + defaultValue: v.defaultValue, + description: v.description, + createdBy: data.createdBy, + })); + await db.insert(approvalTemplateVariables).values(variableRows); + } + + const result = await getApprovalTemplate(newTemplate.id); + if (!result) throw new Error('생성된 템플릿을 조회할 수 없습니다.'); + return result; +} + +// ---------------------------------------------------- +// Update template +// ---------------------------------------------------- +interface UpdateInput { + name?: string; + subject?: string; + content?: string; + description?: string; + category?: string; + approvalLineId?: string | null; + updatedBy: number; +} + +export async function updateApprovalTemplate(id: string, data: UpdateInput): Promise<ApprovalTemplateWithVariables> { + const existing = await getApprovalTemplate(id); + if (!existing) throw new Error('템플릿을 찾을 수 없습니다.'); + + // 버전 계산 - 현재 히스토리 카운트 + 1 + const versionResult = await db + .select({ count: count() }) + .from(approvalTemplateHistory) + .where(eq(approvalTemplateHistory.templateId, id)); + const nextVersion = Number(versionResult[0]?.count ?? 0) + 1; + + // 히스토리 저장 + await db.insert(approvalTemplateHistory).values({ + templateId: id, + version: nextVersion, + subject: existing.subject, + content: existing.content, + changeDescription: '템플릿 업데이트', + changedBy: data.updatedBy, + }); + + // 템플릿 업데이트 + await db + .update(approvalTemplates) + .set({ + name: data.name ?? existing.name, + subject: data.subject ?? existing.subject, + content: data.content ?? existing.content, + description: data.description ?? existing.description, + category: data.category ?? existing.category, + approvalLineId: data.approvalLineId === undefined ? existing.approvalLineId : data.approvalLineId, + updatedAt: new Date(), + }) + .where(eq(approvalTemplates.id, id)); + + const result = await getApprovalTemplate(id); + if (!result) throw new Error('업데이트된 템플릿을 조회할 수 없습니다.'); + return result; +} + +// ---------------------------------------------------- +// Server Actions +// ---------------------------------------------------- +export async function updateApprovalTemplateAction(id: string, data: UpdateInput) { + try { + const updated = await updateApprovalTemplate(id, data) + return { success: true, data: updated } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '업데이트에 실패했습니다.' + } + } +} + +// ---------------------------------------------------- +// Duplicate template +// ---------------------------------------------------- +export async function duplicateApprovalTemplate( + id: string, + newName: string, + createdBy: number, +): Promise<{ success: boolean; error?: string; data?: ApprovalTemplate }> { + try { + const existing = await getApprovalTemplate(id) + if (!existing) return { success: false, error: '템플릿을 찾을 수 없습니다.' } + + // 새 템플릿 생성 + const duplicated = await createApprovalTemplate({ + name: newName, + subject: existing.subject, + content: existing.content, + category: existing.category ?? undefined, + description: existing.description ? `${existing.description} (복사본)` : undefined, + createdBy, + variables: existing.variables?.map((v) => ({ + variableName: v.variableName, + variableType: v.variableType, + defaultValue: v.defaultValue, + description: v.description, + })), + }) + + return { success: true, data: duplicated } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : '복제에 실패했습니다.' } + } +} + +// ---------------------------------------------------- +// Delete (soft delete X -> 실제 삭제) +// ---------------------------------------------------- +export async function deleteApprovalTemplate(id: string): Promise<{ success: boolean; error?: string }> { + try { + await db.delete(approvalTemplates).where(eq(approvalTemplates.id, id)); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : '삭제에 실패했습니다.', + }; + } +}
\ No newline at end of file diff --git a/lib/approval-template/table/approval-template-table-columns.tsx b/lib/approval-template/table/approval-template-table-columns.tsx new file mode 100644 index 00000000..8447c7d2 --- /dev/null +++ b/lib/approval-template/table/approval-template-table-columns.tsx @@ -0,0 +1,165 @@ +"use client" + +import * as React from 'react'; +import { type ColumnDef } from '@tanstack/react-table'; +import { MoreHorizontal, Copy, Trash, Eye } from 'lucide-react'; +import Link from 'next/link'; + +import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { formatDate } from '@/lib/utils'; +import { DataTableColumnHeaderSimple } from '@/components/data-table/data-table-column-simple-header'; +import { type ApprovalTemplate } from '@/lib/approval-template/service'; +import { type DataTableRowAction } from '@/types/table'; + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ApprovalTemplate> | null>> +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ApprovalTemplate>[] { + return [ + { + id: 'select', + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && 'indeterminate') + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: 'name', + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="템플릿 이름" />, + cell: ({ row: _row }) => { + const t = _row.original; + return ( + <div className="flex flex-col gap-1"> + <span className="font-medium">{t.name}</span> + {t.description && ( + <div className="text-xs text-muted-foreground line-clamp-2">{t.description}</div> + )} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + size: 220, + }, + { + accessorKey: 'subject', + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="제목" />, + cell: ({ getValue }) => { + const subject = getValue() as string; + return <div className="text-sm text-muted-foreground">{subject}</div>; + }, + enableSorting: true, + size: 250, + }, + { + accessorKey: 'category', + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="카테고리" />, + cell: ({ getValue }) => { + const category = getValue() as string | null; + if (!category) return <div>-</div>; + return <Badge variant="outline">{category}</Badge>; + }, + enableSorting: true, + size: 120, + }, + { + accessorKey: 'createdAt', + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />, + cell: ({ cell }) => { + const date = cell.getValue() as Date; + return <div className="text-sm text-muted-foreground">{formatDate(date)}</div>; + }, + enableSorting: true, + size: 200, + }, + { + accessorKey: 'updatedAt', + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />, + cell: ({ cell }) => { + const date = cell.getValue() as Date; + return <div className="text-sm text-muted-foreground">{formatDate(date)}</div>; + }, + enableSorting: true, + size: 200, + }, + { + id: 'actions', + cell: ({ row }) => { + const template = row.original; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <MoreHorizontal className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem asChild> + <Link href={`/evcp/approval/template/${template.id}`}> + <Eye className="mr-2 size-4" aria-hidden="true" /> + 상세 보기 + </Link> + </DropdownMenuItem> + + <DropdownMenuItem + onClick={() => { + setRowAction({ type: 'duplicate', row }); + }} + > + <Copy className="mr-2 size-4" aria-hidden="true" /> + 복제하기 + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onClick={() => { + setRowAction({ type: 'delete', row }); + }} + className="text-destructive focus:text-destructive" + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제하기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + enableSorting: false, + enableHiding: false, + size: 80, + }, + ]; +}
\ No newline at end of file diff --git a/lib/approval-template/table/approval-template-table-toolbar-actions.tsx b/lib/approval-template/table/approval-template-table-toolbar-actions.tsx new file mode 100644 index 00000000..08aba97a --- /dev/null +++ b/lib/approval-template/table/approval-template-table-toolbar-actions.tsx @@ -0,0 +1,114 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Plus, Trash } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { type ApprovalTemplate } from "@/lib/approval-template/service" +import { toast } from "sonner" +import { DeleteApprovalTemplateDialog } from "./delete-approval-template-dialog" + +interface ApprovalTemplateTableToolbarActionsProps { + table: Table<ApprovalTemplate> + onCreateTemplate: () => void +} + +export function ApprovalTemplateTableToolbarActions({ + table, + onCreateTemplate, +}: ApprovalTemplateTableToolbarActionsProps) { + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) + + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedTemplates = selectedRows.map((row) => row.original) + + // CSV 내보내기 + const exportToCsv = React.useCallback(() => { + const headers = [ + "이름", + "제목", + "카테고리", + "생성일", + "수정일", + ] + + const csvData = [ + headers, + ...table.getFilteredRowModel().rows.map((row) => { + const t = row.original + return [ + t.name, + t.subject, + t.category ?? "-", + new Date(t.createdAt).toLocaleDateString("ko-KR"), + new Date(t.updatedAt).toLocaleDateString("ko-KR"), + ] + }), + ] + + const csvContent = csvData + .map((row) => row.map((field) => `"${field}"`).join(",")) + .join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const link = document.createElement("a") + + if (link.download !== undefined) { + const url = URL.createObjectURL(blob) + link.setAttribute("href", url) + link.setAttribute( + "download", + `approval_templates_${new Date().toISOString().split("T")[0]}.csv`, + ) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + toast.success("템플릿 목록이 CSV로 내보내졌습니다.") + }, [table]) + + return ( + <div className="flex items-center gap-2"> + {/* 새 템플릿 버튼 */} + <Button variant="default" size="sm" onClick={onCreateTemplate}> + <Plus className="mr-2 size-4" aria-hidden="true" /> + 새 템플릿 + </Button> + + {/* CSV 내보내기 */} + <Button variant="outline" size="sm" onClick={exportToCsv}> + <Download className="mr-2 size-4" aria-hidden="true" /> + 내보내기 + </Button> + + {/* 일괄 삭제 */} + {selectedTemplates.length > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={() => setShowDeleteDialog(true)} + className="text-destructive hover:text-destructive" + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({selectedTemplates.length}) + </Button> + + <DeleteApprovalTemplateDialog + open={showDeleteDialog} + onOpenChange={setShowDeleteDialog} + templates={selectedTemplates} + showTrigger={false} + onSuccess={() => { + table.toggleAllRowsSelected(false) + setShowDeleteDialog(false) + }} + /> + </> + )} + </div> + ) +} diff --git a/lib/approval-template/table/approval-template-table.tsx b/lib/approval-template/table/approval-template-table.tsx new file mode 100644 index 00000000..d7f0e478 --- /dev/null +++ b/lib/approval-template/table/approval-template-table.tsx @@ -0,0 +1,135 @@ +"use client"; + +import * as React from 'react'; +import { useDataTable } from '@/hooks/use-data-table'; +import { DataTable } from '@/components/data-table/data-table'; +import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from '@/types/table'; + +import { getColumns } from './approval-template-table-columns'; +import { getApprovalTemplateList } from '../service'; +import { type ApprovalTemplate } from '../service'; +import { ApprovalTemplateTableToolbarActions } from './approval-template-table-toolbar-actions'; +import { CreateApprovalTemplateSheet } from './create-approval-template-sheet'; +import { UpdateApprovalTemplateSheet } from './update-approval-template-sheet'; +import { DuplicateApprovalTemplateSheet } from './duplicate-approval-template-sheet'; +import { DeleteApprovalTemplateDialog } from './delete-approval-template-dialog'; + +interface ApprovalTemplateTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getApprovalTemplateList>>, + ]>; +} + +export function ApprovalTemplateTable({ promises }: ApprovalTemplateTableProps) { + const [{ data, pageCount }] = React.use(promises); + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ApprovalTemplate> | null>(null); + + const [showCreateSheet, setShowCreateSheet] = React.useState(false); + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ); + + // 기본 & 고급 필터 필드 (추후 확장 가능) + const filterFields: DataTableFilterField<ApprovalTemplate>[] = []; + const advancedFilterFields: DataTableAdvancedFilterField<ApprovalTemplate>[] = [ + { + id: 'name', + label: '템플릿 이름', + type: 'text', + }, + { + id: 'subject', + label: '제목', + type: 'text', + }, + { + id: 'category', + label: '카테고리', + type: 'text', + }, + { + id: 'createdAt', + label: '생성일', + type: 'date', + }, + { + id: 'updatedAt', + label: '수정일', + type: 'date', + }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'updatedAt', desc: true }], + columnPinning: { right: ['actions'] }, + }, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }); + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <ApprovalTemplateTableToolbarActions + table={table} + onCreateTemplate={() => setShowCreateSheet(true)} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 새 템플릿 생성 Sheet */} + <CreateApprovalTemplateSheet + open={showCreateSheet} + onOpenChange={setShowCreateSheet} + /> + + {/* 템플릿 수정 Sheet */} + <UpdateApprovalTemplateSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + template={rowAction?.type === "update" ? rowAction.row.original : null} + /> + + {/* 템플릿 복제 Sheet */} + <DuplicateApprovalTemplateSheet + open={rowAction?.type === "duplicate"} + onOpenChange={() => setRowAction(null)} + template={rowAction?.type === "duplicate" ? rowAction.row.original : null} + /> + + {/* 템플릿 삭제 Dialog */} + <DeleteApprovalTemplateDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + templates={rowAction?.type === "delete" ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null) + // 테이블 새로고침은 server action에서 자동으로 처리됨 + }} + /> + </> + ); +}
\ No newline at end of file diff --git a/lib/approval-template/table/create-approval-template-sheet.tsx b/lib/approval-template/table/create-approval-template-sheet.tsx new file mode 100644 index 00000000..7e899175 --- /dev/null +++ b/lib/approval-template/table/create-approval-template-sheet.tsx @@ -0,0 +1,174 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" + +import { createApprovalTemplate } from "../service" + +const createSchema = z.object({ + name: z.string().min(1, "이름은 필수입니다").max(100, "100자 이하"), + subject: z.string().min(1, "제목은 필수입니다").max(200, "200자 이하"), + category: z.string().optional(), + description: z.string().optional(), +}) + +type CreateSchema = z.infer<typeof createSchema> + +interface CreateApprovalTemplateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {} + +export function CreateApprovalTemplateSheet({ ...props }: CreateApprovalTemplateSheetProps) { + const [isPending, startTransition] = React.useTransition() + const router = useRouter() + const { data: session } = useSession() + + const form = useForm<CreateSchema>({ + resolver: zodResolver(createSchema), + defaultValues: { + name: "", + subject: "", + category: undefined, + description: "", + }, + }) + + function onSubmit(values: CreateSchema) { + startTransition(async () => { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + const defaultContent = `<p>{{content}}</p>` + + try { + const template = await createApprovalTemplate({ + name: values.name, + subject: values.subject, + content: defaultContent, + category: values.category || undefined, + description: values.description || undefined, + createdBy: Number(session.user.id), + variables: [], + }) + + toast.success("템플릿이 생성되었습니다") + props.onOpenChange?.(false) + + router.push(`/evcp/approval/template/${template.id}`) + } catch (error) { + toast.error(error instanceof Error ? error.message : "생성에 실패했습니다") + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>새 템플릿 생성</SheetTitle> + <SheetDescription>새로운 결재 템플릿을 생성합니다.</SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>템플릿 이름</FormLabel> + <FormControl> + <Input placeholder="예: 견적 승인 요청" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="subject" + render={({ field }) => ( + <FormItem> + <FormLabel>제목</FormLabel> + <FormControl> + <Input placeholder="예: 견적 승인 요청" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>카테고리 (선택)</FormLabel> + <FormControl> + <Input placeholder="카테고리" {...field} /> + </FormControl> + <FormDescription>카테고리를 입력하지 않으면 미분류로 저장됩니다.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명 (선택)</FormLabel> + <FormControl> + <Input placeholder="설명" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />} + 생성 후 편집하기 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} diff --git a/lib/approval-template/table/delete-approval-template-dialog.tsx b/lib/approval-template/table/delete-approval-template-dialog.tsx new file mode 100644 index 00000000..9fa0a078 --- /dev/null +++ b/lib/approval-template/table/delete-approval-template-dialog.tsx @@ -0,0 +1,123 @@ +"use client" + +import * as React from "react" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { type ApprovalTemplate } from "@/lib/approval-template/service" + +import { deleteApprovalTemplate } from "../service" + +interface DeleteApprovalTemplateDialogProps extends React.ComponentPropsWithRef<typeof Dialog> { + templates: ApprovalTemplate[] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteApprovalTemplateDialog({ + templates, + showTrigger = true, + onSuccess, + ...props +}: DeleteApprovalTemplateDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + + const isMultiple = templates.length > 1 + const templateName = isMultiple ? `${templates.length}개 템플릿` : templates[0]?.name + + function onDelete() { + startDeleteTransition(async () => { + if (templates.length === 0) return + + // 여러 개면 순차 삭제 (간단히 처리) + for (const t of templates) { + const result = await deleteApprovalTemplate(t.id) + if (result.error) { + toast.error(result.error) + return + } + } + + props.onOpenChange?.(false) + onSuccess?.() + toast.success( + isMultiple ? `${templates.length}개 템플릿이 삭제되었습니다` : "템플릿이 삭제되었습니다", + ) + + // 새로고침으로 반영 + window.location.reload() + }) + } + + return ( + <Dialog {...props}> + {showTrigger && ( + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + className="text-destructive hover:text-destructive" + > + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 + </Button> + </DialogTrigger> + )} + <DialogContent> + <DialogHeader> + <DialogTitle>템플릿 삭제 확인</DialogTitle> + <DialogDescription> + 정말로 <strong>{templateName}</strong>을(를) 삭제하시겠습니까? + {!isMultiple && ( + <> + <br />이 작업은 되돌릴 수 없습니다. + </> + )} + </DialogDescription> + </DialogHeader> + + {templates.length > 0 && ( + <div className="rounded-lg bg-muted p-3 max-h-40 overflow-y-auto"> + <h4 className="text-sm font-medium mb-2">삭제될 템플릿</h4> + <div className="space-y-1"> + {templates.map((t) => ( + <div key={t.id} className="text-xs text-muted-foreground"> + <div className="font-medium">{t.name}</div> + <div>ID: <code className="bg-background px-1 rounded">{t.id}</code></div> + </div> + ))} + </div> + </div> + )} + + <DialogFooter> + <Button + variant="outline" + onClick={() => props.onOpenChange?.(false)} + disabled={isDeletePending} + > + 취소 + </Button> + <Button + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />} + {isMultiple ? `${templates.length}개 삭제` : "삭제"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/approval-template/table/duplicate-approval-template-sheet.tsx b/lib/approval-template/table/duplicate-approval-template-sheet.tsx new file mode 100644 index 00000000..e49000ff --- /dev/null +++ b/lib/approval-template/table/duplicate-approval-template-sheet.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" + +import { type ApprovalTemplate } from "@/lib/approval-template/service" +import { duplicateApprovalTemplate } from "../service" +import { useSession } from "next-auth/react" + +const duplicateSchema = z.object({ + name: z.string().min(1, "템플릿 이름은 필수입니다").max(100, "100자 이내여야 합니다"), +}) + +type DuplicateSchema = z.infer<typeof duplicateSchema> + +interface DuplicateApprovalTemplateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + template: ApprovalTemplate | null +} + +export function DuplicateApprovalTemplateSheet({ template, ...props }: DuplicateApprovalTemplateSheetProps) { + const [isPending, startTransition] = React.useTransition() + const router = useRouter() + const { data: session } = useSession() + + const form = useForm<DuplicateSchema>({ + resolver: zodResolver(duplicateSchema), + defaultValues: { name: "" }, + }) + + React.useEffect(() => { + if (template) { + form.reset({ name: `${template.name} (복사본)` }) + } + }, [template, form]) + + function onSubmit(values: DuplicateSchema) { + startTransition(async () => { + if (!template) return + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + const { success, error, data } = await duplicateApprovalTemplate( + template.id, + values.name, + Number(session.user.id), + ) + + if (!success || error) { + toast.error(error ?? "복제에 실패했습니다") + return + } + + toast.success("템플릿이 복제되었습니다") + props.onOpenChange?.(false) + + // 상세 페이지로 이동 (id 기반) + if (data?.id) { + router.push(`/evcp/approval/template/${data.id}`) + } else { + window.location.reload() + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>템플릿 복제</SheetTitle> + <SheetDescription>기존 템플릿을 복사하여 새로운 템플릿을 생성합니다.</SheetDescription> + </SheetHeader> + + {template && ( + <div className="rounded-lg bg-muted p-3"> + <h4 className="text-sm font-medium mb-2">원본 템플릿</h4> + <div className="space-y-1 text-xs text-muted-foreground"> + <div>이름: {template.name}</div> + <div>ID: <code className="bg-background px-1 rounded">{template.id}</code></div> + <div>카테고리: {template.category ?? "-"}</div> + </div> + </div> + )} + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>새 템플릿 이름</FormLabel> + <FormControl> + <Input placeholder="복제된 템플릿 이름" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />} + 복제하기 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} diff --git a/lib/approval-template/table/update-approval-template-sheet.tsx b/lib/approval-template/table/update-approval-template-sheet.tsx new file mode 100644 index 00000000..05f4069c --- /dev/null +++ b/lib/approval-template/table/update-approval-template-sheet.tsx @@ -0,0 +1,23 @@ +"use client" + +import * as React from "react" +import { Sheet } from "@/components/ui/sheet" +import { type ApprovalTemplate } from "@/lib/approval-template/service" + +interface UpdateApprovalTemplateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + template: ApprovalTemplate | null +} + +export function UpdateApprovalTemplateSheet({ template, ...props }: UpdateApprovalTemplateSheetProps) { + // 현재 프로젝트 요구사항 범위 내에서는 상세 편집 페이지 또는 별도 에디터가 준비되지 않았으므로 + // 업데이트 시트는 추후 구현합니다. (이메일 템플릿에서는 사용되지 않아 주석 처리되어 있었음) + // 이를 사용하려 할 경우 안내 문구만 표시합니다. + + return ( + <Sheet {...props}> + <div className="p-6 text-sm text-muted-foreground"> + 템플릿 편집 기능은 아직 구현되지 않았습니다. + </div> + </Sheet> + ) +} diff --git a/lib/approval-template/validations.ts b/lib/approval-template/validations.ts new file mode 100644 index 00000000..3b60e478 --- /dev/null +++ b/lib/approval-template/validations.ts @@ -0,0 +1,27 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from 'nuqs/server'; +import { z } from 'zod'; + +import { getFiltersStateParser, getSortingStateParser } from '@/lib/parsers'; +import { approvalTemplates } from '@/db/schema/knox/approvals'; + +export const SearchParamsApprovalTemplateCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(['advancedTable', 'floatingBar'])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<typeof approvalTemplates>().withDefault([ + { id: 'updatedAt', desc: true }, + ]), + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(['and', 'or']).withDefault('and'), + search: parseAsString.withDefault(''), +}); + +export type GetApprovalTemplateSchema = Awaited< + ReturnType<typeof SearchParamsApprovalTemplateCache.parse> +>;
\ No newline at end of file diff --git a/lib/knox-api/approval/service.ts b/lib/knox-api/approval/service.ts index 6ef1b1f6..0bd817a6 100644 --- a/lib/knox-api/approval/service.ts +++ b/lib/knox-api/approval/service.ts @@ -1,7 +1,7 @@ "use server" import db from '@/db/db'; import { ApprovalLine } from "./approval"; -import { approval } from '@/db/schema/knox/approvals'; +import { approvalLogs } from '@/db/schema/knox/approvals'; import { eq, and } from 'drizzle-orm'; // ========== 데이터베이스 서비스 함수들 ========== @@ -21,7 +21,7 @@ export async function saveApprovalToDatabase( aplns: ApprovalLine[] ): Promise<void> { try { - await db.insert(approval).values({ + await db.insert(approvalLogs).values({ apInfId, userId, epId, @@ -51,12 +51,12 @@ export async function updateApprovalStatus( ): Promise<void> { try { await db - .update(approval) + .update(approvalLogs) .set({ status, updatedAt: new Date(), }) - .where(eq(approval.apInfId, apInfId)); + .where(eq(approvalLogs.apInfId, apInfId)); } catch (error) { console.error('결재 상태 업데이트 실패:', error); throw new Error('결재 상태를 업데이트하는 중 오류가 발생했습니다.'); @@ -69,15 +69,15 @@ export async function updateApprovalStatus( export async function getApprovalFromDatabase( apInfId: string, includeDeleted: boolean = false -): Promise<typeof approval.$inferSelect | null> { +): Promise<typeof approvalLogs.$inferSelect | null> { try { const whereCondition = includeDeleted - ? eq(approval.apInfId, apInfId) - : and(eq(approval.apInfId, apInfId), eq(approval.isDeleted, false)); + ? eq(approvalLogs.apInfId, apInfId) + : and(eq(approvalLogs.apInfId, apInfId), eq(approvalLogs.isDeleted, false)); const result = await db .select() - .from(approval) + .from(approvalLogs) .where(whereCondition) .limit(1); @@ -96,17 +96,17 @@ export async function getApprovalsByUser( limit: number = 50, offset: number = 0, includeDeleted: boolean = false -): Promise<typeof approval.$inferSelect[]> { +): Promise<typeof approvalLogs.$inferSelect[]> { try { const whereCondition = includeDeleted - ? eq(approval.userId, userId) - : and(eq(approval.userId, userId), eq(approval.isDeleted, false)); + ? eq(approvalLogs.userId, userId) + : and(eq(approvalLogs.userId, userId), eq(approvalLogs.isDeleted, false)); const result = await db .select() - .from(approval) + .from(approvalLogs) .where(whereCondition) - .orderBy(approval.createdAt) + .orderBy(approvalLogs.createdAt) .limit(limit) .offset(offset); @@ -125,12 +125,12 @@ export async function deleteApprovalFromDatabase( ): Promise<void> { try { await db - .update(approval) + .update(approvalLogs) .set({ isDeleted: true, updatedAt: new Date(), }) - .where(eq(approval.apInfId, apInfId)); + .where(eq(approvalLogs.apInfId, apInfId)); } catch (error) { console.error('결재 데이터 삭제 실패:', error); throw new Error('결재 데이터를 삭제하는 중 오류가 발생했습니다.'); diff --git a/lib/knox-api/organization-service.ts b/lib/knox-api/organization-service.ts new file mode 100644 index 00000000..c90b00f7 --- /dev/null +++ b/lib/knox-api/organization-service.ts @@ -0,0 +1,95 @@ +'use server'; + +import db from '@/db/db'; +import { organization as organizationTable } from '@/db/schema/knox/organization'; +import { count, ilike, isNotNull } from 'drizzle-orm'; + + +// ---------------------------------------------------- +// 조직 관리자 검색 함수 +// ---------------------------------------------------- + +interface SearchOrganizationsForManagerInput { + search: string; + page: number; + perPage: number; +} + +interface SearchOrganizationsForManagerResult { + data: Array<{ + id: string; + departmentCode: string; + departmentName: string; + managerId: string; + managerName: string; + managerTitle: string; + companyCode: string; + companyName: string; + }>; + total: number; + pageCount: number; +} + +export async function searchOrganizationsForManager( + input: SearchOrganizationsForManagerInput +): Promise<SearchOrganizationsForManagerResult> { + const offset = (input.page - 1) * input.perPage; + + // 검색 조건 구성 + const searchTerm = `%${input.search}%`; + + const results = await db + .select({ + id: organizationTable.departmentCode, + departmentCode: organizationTable.departmentCode, + departmentName: organizationTable.departmentName, + managerId: organizationTable.managerId, + managerName: organizationTable.managerName, + managerTitle: organizationTable.managerTitle, + companyCode: organizationTable.companyCode, + companyName: organizationTable.companyName, + }) + .from(organizationTable) + .where( + // 관리자가 있고, 부서명 또는 관리자명으로 검색 + isNotNull(organizationTable.managerId) + ) + .having( + // 부서명 또는 관리자명으로 검색 + ilike(organizationTable.departmentName, searchTerm) || + ilike(organizationTable.managerName, searchTerm) + ) + .orderBy(organizationTable.departmentName) + .limit(input.perPage) + .offset(offset); + + // 전체 개수 조회 + const totalResult = await db + .select({ count: count() }) + .from(organizationTable) + .where( + isNotNull(organizationTable.managerId) + ) + .having( + ilike(organizationTable.departmentName, searchTerm) || + ilike(organizationTable.managerName, searchTerm) + ); + + const total = totalResult[0]?.count ?? 0; + const pageCount = Math.ceil(total / input.perPage); + + return { + data: results as Array<{ + id: string; + departmentCode: string; + departmentName: string; + managerId: string; + managerName: string; + managerTitle: string; + companyCode: string; + companyName: string; + }>, + total, + pageCount, + }; +}
\ No newline at end of file diff --git a/lib/nonsap-sync/procurement-sync-service.ts b/lib/nonsap-sync/procurement-sync-service.ts index 1f719526..c8365a1c 100644 --- a/lib/nonsap-sync/procurement-sync-service.ts +++ b/lib/nonsap-sync/procurement-sync-service.ts @@ -25,7 +25,7 @@ const logger = { async function testOracleConnection(): Promise<boolean> { try { const result = await oracleKnex.raw('SELECT 1 FROM DUAL'); - return result.rows && result.rows.length > 0; + return result && result.length > 0; } catch (error) { logger.error('Oracle DB 연결 테스트 실패:', error); return false; @@ -46,7 +46,7 @@ async function syncPaymentTerms(): Promise<void> { WHERE stc.cd = 'PAYT' `); - const paymentTermsData = oracleData.rows || []; + const paymentTermsData = oracleData || []; logger.info(`Oracle에서 ${paymentTermsData.length}개의 지불조건 데이터 조회`); if (paymentTermsData.length === 0) { @@ -64,7 +64,7 @@ async function syncPaymentTerms(): Promise<void> { // 업데이트할 데이터 준비 const upsertData = paymentTermsData.map((item: { CODE: string; DESCRIPTION: string }) => ({ code: item.CODE, - description: item.DESCRIPTION, + description: item.DESCRIPTION || 'NONSAP에서 설명 기입 요망', isActive: true // 기본값 true })); @@ -112,7 +112,7 @@ async function syncIncoterms(): Promise<void> { WHERE stc.cd = 'INCO' `); - const incotermsData = oracleData.rows || []; + const incotermsData = oracleData || []; logger.info(`Oracle에서 ${incotermsData.length}개의 인코텀즈 데이터 조회`); if (incotermsData.length === 0) { @@ -130,7 +130,7 @@ async function syncIncoterms(): Promise<void> { // 업데이트할 데이터 준비 const upsertData = incotermsData.map((item: { CODE: string; DESCRIPTION: string }) => ({ code: item.CODE, - description: item.DESCRIPTION, + description: item.DESCRIPTION || 'NONSAP에서 설명 기입 요망', isActive: true // 기본값 true })); @@ -181,7 +181,7 @@ async function syncPlaceOfShipping(): Promise<void> { ORDER BY cd.CD asc, cdnm.CDNM asc `); - const placeOfShippingData = oracleData.rows || []; + const placeOfShippingData = oracleData || []; logger.info(`Oracle에서 ${placeOfShippingData.length}개의 선적/하역지 데이터 조회`); if (placeOfShippingData.length === 0) { @@ -199,7 +199,7 @@ async function syncPlaceOfShipping(): Promise<void> { // 업데이트할 데이터 준비 (isActive = "Y"인 경우 true, 그 외는 기본값 true) const upsertData = placeOfShippingData.map((item: { CODE: string; DESCRIPTION: string; ISACTIVE: string }) => ({ code: item.CODE, - description: item.DESCRIPTION, + description: item.DESCRIPTION || 'NONSAP에서 설명 기입 요망', isActive: item.ISACTIVE === 'Y' ? true : true // 기본값 true })); diff --git a/lib/pdftron/serverSDK/createBasicContractPdf.ts b/lib/pdftron/serverSDK/createBasicContractPdf.ts index 706508e6..6b7a4baf 100644 --- a/lib/pdftron/serverSDK/createBasicContractPdf.ts +++ b/lib/pdftron/serverSDK/createBasicContractPdf.ts @@ -1,17 +1,16 @@ -const { PDFNet } = require("@pdftron/pdfnet-node"); -const fs = require('fs').promises; -const path = require('path'); +import { PDFNet } from "@pdftron/pdfnet-node"; +import { promises as fs } from "fs"; import { file as tmpFile } from "tmp-promise"; type CreateBasicContractPdf = ( templateBuffer: Buffer, templateData: { - [key: string]: any; + [key: string]: unknown; } ) => Promise<{ result: boolean; buffer?: ArrayBuffer; - error?: any; + error?: unknown; }>; export const createBasicContractPdf: CreateBasicContractPdf = async ( diff --git a/lib/soap/ecc-mapper.ts b/lib/soap/ecc-mapper.ts new file mode 100644 index 00000000..030d6973 --- /dev/null +++ b/lib/soap/ecc-mapper.ts @@ -0,0 +1,429 @@ +import { debugLog, debugSuccess, debugError } from '@/lib/debug-utils'; +import db from '@/db/db'; +import { + procurementRfqs, + prItems, + procurementRfqDetails, +} from '@/db/schema/procurementRFQ'; +import { + PR_INFORMATION_T_BID_HEADER, + PR_INFORMATION_T_BID_ITEM, +} from '@/db/schema/ECC/ecc'; +import { users } from '@/db/schema'; +import { employee } from '@/db/schema/knox/employee'; +import { eq, inArray } from 'drizzle-orm'; + +// NON-SAP 데이터 처리 +import { oracleKnex } from '@/lib/oracle-db/db'; +import { findUserIdByEmployeeNumber } from '../users/knox-service'; + +// ECC 데이터 타입 정의 +export type ECCBidHeader = typeof PR_INFORMATION_T_BID_HEADER.$inferInsert; +export type ECCBidItem = typeof PR_INFORMATION_T_BID_ITEM.$inferInsert; + +// 비즈니스 테이블 데이터 타입 정의 +export type ProcurementRfqData = typeof procurementRfqs.$inferInsert; +export type PrItemData = typeof prItems.$inferInsert; +export type ProcurementRfqDetailData = + typeof procurementRfqDetails.$inferInsert; + +/** + * 시리즈 판단: 관련 PR 아이템들의 PSPID를 기반으로 결정 + * - 아이템이 1개 이하: null + * - 아이템이 2개 이상이고 PSPID가 모두 동일: "SS" + * - 아이템이 2개 이상이고 PSPID가 서로 다름: "||" + */ +function computeSeriesFromItems(items: ECCBidItem[]): string | null { + if (items.length <= 1) return null; + + const normalize = (v: unknown): string | null => { + if (typeof v !== 'string') return null; + const trimmed = v.trim(); + return trimmed.length > 0 ? trimmed : null; + }; + + const uniquePspids = new Set<string | null>( + items.map((it) => normalize(it.PSPID as string | null | undefined)) + ); + + return uniquePspids.size === 1 ? 'SS' : '||'; +} + +/** + * PERNR(사번)을 기준으로 사용자 ID를 찾는 함수 + */ +async function findUserIdByPernr(pernr: string): Promise<number | null> { + try { + debugLog('PERNR로 사용자 ID 찾기 시작', { pernr }); + + // 현재 users 테이블에 사번을 따로 저장하지 않으므로 knox 기준으로 사번 --> epId --> user.id 순으로 찾기 + // 1. employee 테이블에서 employeeNumber로 epId 찾기 + const employeeResult = await db + .select({ epId: employee.epId }) + .from(employee) + .where(eq(employee.employeeNumber, pernr)) + .limit(1); + + if (employeeResult.length === 0) { + debugError('사번에 해당하는 직원 정보를 찾을 수 없음', { pernr }); + return null; + } + + const epId = employeeResult[0].epId; + debugLog('직원 epId 찾음', { pernr, epId }); + + // 2. users 테이블에서 epId로 사용자 ID 찾기 + const userResult = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.epId, epId)) + .limit(1); + + if (userResult.length === 0) { + debugError('epId에 해당하는 사용자 정보를 찾을 수 없음', { epId }); + return null; + } + + const userId = userResult[0].id; + debugSuccess('사용자 ID 찾음', { pernr, epId, userId }); + return userId; + } catch (error) { + debugError('사용자 ID 찾기 중 오류 발생', { pernr, error }); + return null; + } +} + + + +/** + * 담당자 찾는 함수 (Non-SAP 데이터를 기준으로 찾음) + * Puchasing Group을 CMCTB_CD 에서 CD_CLF='MMA070' 조건으로, CD=EKGRP 조건으로 찾으면, USR_DF_CHAR_9 컬럼이 담당자 사번임. 기준으로 유저를 넣어줄 예정임 + */ +async function findInChargeUserIdByEKGRP(EKGRP: string | null): Promise<number | null> { + try { + debugLog('담당자 찾기 시작', { EKGRP }); + // NonSAP에서 담당자 사번 찾기 + + const result = await oracleKnex + .select('USR_DF_CHAR_9') + .from('CMCTB_CD') + .where('CD_CLF', 'MMA070') + .andWhere('CD', EKGRP) + .limit(1); + + if (result.length === 0) { + debugError('담당자 찾기 중 오류 발생', { EKGRP }); + return null; + } + + const employeeNumber = result[0].USR_DF_CHAR_9; + + // 임시 : Knox API 기준으로 사번에 해당하는 userId 찾기 (nonsap에서 제공하는 유저테이블로 변경 예정) + const userId = await findUserIdByEmployeeNumber(employeeNumber); + + debugSuccess('담당자 찾음', { EKGRP, userId }); + return userId; + } catch (error) { + debugError('담당자 찾기 중 오류 발생', { EKGRP, error }); + return null; + } +} + +// *****************************mapping functions********************************* + +/** + * ECC RFQ 헤더 데이터를 비즈니스 테이블로 매핑 + */ +export async function mapECCRfqHeaderToBusiness( + eccHeader: ECCBidHeader +): Promise<ProcurementRfqData> { + debugLog('ECC RFQ 헤더 매핑 시작', { anfnr: eccHeader.ANFNR }); + + // 날짜 파싱 (실패시 현재 Date 들어감) + let interfacedAt: Date = new Date(); + + if (eccHeader.ZRFQ_TRS_DT != null && eccHeader.ZRFQ_TRS_TM != null) { + try { + // SAP 날짜 형식 (YYYYMMDD) 파싱 + const dateStr = eccHeader.ZRFQ_TRS_DT; + if (dateStr.length === 8) { + const year = parseInt(dateStr.substring(0, 4)); + const month = parseInt(dateStr.substring(4, 6)) - 1; // 0-based + const day = parseInt(dateStr.substring(6, 8)); + const hour = parseInt(eccHeader.ZRFQ_TRS_TM.substring(0, 2)); + const minute = parseInt(eccHeader.ZRFQ_TRS_TM.substring(2, 4)); + const second = parseInt(eccHeader.ZRFQ_TRS_TM.substring(4, 6)); + interfacedAt = new Date(year, month, day, hour, minute, second); + } + } catch (error) { + debugError('날짜 파싱 오류', { + date: eccHeader.ZRFQ_TRS_DT, + time: eccHeader.ZRFQ_TRS_TM, + error, + }); + } + } + + // 담당자 찾기 + const inChargeUserId = await findInChargeUserIdByEKGRP(eccHeader.EKGRP || null); + + // 시리즈는 3가지의 케이스만 온다고 가정한다. (다른 케이스는 잘못된 것) + // 케이스 1. 한 RFQ에서 PR Item이 여러 개고, PSPID 값이 모두 같은 경우 => series 값은 "SS" + // 케이스 2. 한 RFQ에서 PR Item이 여러 개고, PSPID 값이 여러개인 경우 => series 값은 "||"" + // 케이스 3. 한 RFQ에서 PR Item이 하나인 경우 => seires 값은 null + // 만약 위 케이스에 모두 속하지 않는 경우 케이스 3처럼 null 값을 넣는다. + + // 매핑 + const mappedData: ProcurementRfqData = { + rfqCode: eccHeader.ANFNR, // rfqCode=rfqNumber(ANFNR), 혼선이 있을 수 있으나 ECC에는 rfqCode라는 게 별도로 없음. rfqCode=rfqNumber + projectId: null, // PR 아이템 처리 후 업데이트. (로직은 추후 작성) + series: null, // PR 아이템 처리 후 업데이트. (로직은 추후 작성) + // itemGroup: null, // 대표 자재그룹: PR 아이템 처리 후 업데이트. (로직은 추후 작성) + itemCode: null, // 대표 자재코드: PR 아이템 처리 후 업데이트. (로직은 추후 작성) + itemName: null, // 대표 자재명: PR 아이템 처리 후 업데이트. (로직은 추후 작성) + dueDate: null, // eVCP에서 사용하는 컬럼이므로 불필요. + rfqSendDate: null, // eVCP에서 사용하는 컬럼이므로 불필요. + createdAt: interfacedAt, + status: 'RFQ Created', // eVCP에서 사용하는 컬럼, 기본값 RFQ Created 처리 + rfqSealedYn: false, // eVCP에서 사용하는 컬럼, 기본값 false 처리 + picCode: eccHeader.EKGRP || null, // Purchasing Group을 PIC로 사용 (구매측 임직원과 연계된 코드임) + remark: null, // remark 컬럼은 담당자 메모용으로 넣어줄 필요 없음. + sentBy: null, // 보내기 전의 RFQ를 대상으로 하므로 넣어줄 필요 없음. + createdBy: inChargeUserId || 1, + updatedBy: inChargeUserId || 1, + }; + + debugSuccess('ECC RFQ 헤더 매핑 완료', { anfnr: eccHeader.ANFNR }); + return mappedData; +} + +/** + * ECC RFQ 아이템 데이터를 비즈니스 테이블로 매핑 + */ +export function mapECCRfqItemToBusiness( + eccItem: ECCBidItem, + rfqId: number +): PrItemData { + debugLog('ECC RFQ 아이템 매핑 시작', { + anfnr: eccItem.ANFNR, + anfps: eccItem.ANFPS, + }); + + // 날짜 파싱 + let deliveryDate: Date | null = null; + if (eccItem.LFDAT) { + try { + const dateStr = eccItem.LFDAT; + if (dateStr.length === 8) { + const year = parseInt(dateStr.substring(0, 4)); + const month = parseInt(dateStr.substring(4, 6)) - 1; + const day = parseInt(dateStr.substring(6, 8)); + deliveryDate = new Date(year, month, day); + } + } catch (error) { + debugError('아이템 날짜 파싱 오류', { date: eccItem.LFDAT, error }); + } + } + + // TODO: 시리즈인 경우 EBELP(Series PO Item Seq) 를 참조하는 로직 필요? 이 컬럼의 의미 확인 필요 + + + const mappedData: PrItemData = { + procurementRfqsId: rfqId, // PR Item의 부모 RFQ ID [ok] + rfqItem: eccItem.ANFPS || null, // itemNo [ok] + prItem: eccItem.BANPO || null, // ECC PR No [ok] + prNo: eccItem.BANFN || null, // ECC PR No [ok] + materialCode: eccItem.MATNR || null, // ECC Material Number [ok] + materialCategory: eccItem.MATKL || null, // ECC Material Group [ok] + acc: eccItem.SAKTO || null, // ECC G/L Account Number [ok] + materialDescription: eccItem.TXZ01 || null, // ECC Short Text [ok] // TODO: 자재 테이블 참조해서 자재명 넣어주기 ? + size: null, // ECC에서 해당 정보 없음 // TODO: 이시원 프로에게 확인 + deliveryDate, // ECC PR Delivery Date (parsed) + quantity: eccItem.MENGE ? Number(eccItem.MENGE) : null, // ECC PR Quantity [ok] + uom: eccItem.MEINS || null, // ECC PR UOM [ok] + grossWeight: eccItem.BRGEW ? Number(eccItem.BRGEW) : null, // ECC PR Gross Weight [ok] + gwUom: eccItem.GEWEI || null, // ECC PR Gross Weight UOM [ok] + specNo: null, // ECC에서 해당 정보 없음, TODO: 이시원 프로 - material 참조해서 넣어주는건지, PR 마다 고유한건지 확인 + specUrl: null, // ECC에서 해당 정보 없음, TODO: 이시원 프로에게 material 참조해서 넣어주는건지, PR 마다 고유한건지 확인 + trackingNo: null, // TODO: 이시원 프로에게 확인 필요. I/F 정의서 어느 항목인지 추정 불가 + majorYn: false, // 기본값 false 할당, 필요시 eVCP에서 수정 + projectDef: eccItem.PSPID || null, // Project Key 로 처리. // TODO: 프로젝트 테이블 참조해 코드로 처리하기 + projectSc: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기 + projectKl: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기 + projectLc: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기 + projectDl: null, // ECC에서 해당 정보 없음 // TODO: pspid 기준으로 찾아 넣어주기 + remark: null, // remark 컬럼은 담당자 메모용으로 넣어줄 필요 없음. + }; + + debugSuccess('ECC RFQ 아이템 매핑 완료', { + rfqItem: eccItem.ANFPS, + materialCode: eccItem.MATNR, + }); + return mappedData; +} + +/** + * ECC 데이터를 비즈니스 테이블로 일괄 매핑 및 저장 + */ +export async function mapAndSaveECCRfqData( + eccHeaders: ECCBidHeader[], + eccItems: ECCBidItem[] +): Promise<{ success: boolean; message: string; processedCount: number }> { + debugLog('ECC 데이터 일괄 매핑 및 저장 시작', { + headerCount: eccHeaders.length, + itemCount: eccItems.length, + }); + + try { + const result = await db.transaction(async (tx) => { + // 1) 헤더별 관련 아이템 그룹핑 + 시리즈 계산 + 헤더 매핑을 병렬로 수행 + const rfqGroups = await Promise.all( + eccHeaders.map(async (eccHeader) => { + const relatedItems = eccItems.filter((item) => item.ANFNR === eccHeader.ANFNR); + const series = computeSeriesFromItems(relatedItems); + const rfqData = await mapECCRfqHeaderToBusiness(eccHeader); + rfqData.series = series; + return { rfqCode: rfqData.rfqCode, rfqData, relatedItems }; + }) + ); + + const rfqRecords = rfqGroups.map((g) => g.rfqData); + + // 2) RFQ 다건 삽입 (중복은 무시). 반환된 레코드로 일부 ID 매핑 + const inserted = await tx + .insert(procurementRfqs) + .values(rfqRecords) + .onConflictDoNothing() + .returning({ id: procurementRfqs.id, rfqCode: procurementRfqs.rfqCode }); + + const rfqCodeToId = new Map<string, number>(); + for (const row of inserted) { + if (row.rfqCode) { + rfqCodeToId.set(row.rfqCode, row.id); + } + } + + // 3) 반환되지 않은 기존 RFQ 들의 ID 조회하여 매핑 보완 + const allCodes = rfqRecords + .map((r) => r.rfqCode) + .filter((c): c is string => typeof c === 'string' && c.length > 0); + const missingCodes = allCodes.filter((c) => !rfqCodeToId.has(c)); + if (missingCodes.length > 0) { + const existing = await tx + .select({ id: procurementRfqs.id, rfqCode: procurementRfqs.rfqCode }) + .from(procurementRfqs) + .where(inArray(procurementRfqs.rfqCode, missingCodes)); + for (const row of existing) { + if (row.rfqCode) { + rfqCodeToId.set(row.rfqCode, row.id); + } + } + } + + // 4) 모든 아이템을 한 번에 생성할 데이터로 변환 + const allItemsToInsert: PrItemData[] = []; + for (const group of rfqGroups) { + const rfqCode = group.rfqCode; + if (!rfqCode) continue; + const rfqId = rfqCodeToId.get(rfqCode); + if (!rfqId) { + debugError('RFQ ID 매핑 누락', { rfqCode }); + throw new Error(`RFQ ID를 찾을 수 없습니다: ${rfqCode}`); + } + + for (const eccItem of group.relatedItems) { + const itemData = mapECCRfqItemToBusiness(eccItem, rfqId); + allItemsToInsert.push(itemData); + } + } + + // 5) 아이템 일괄 삽입 (chunk 처리로 파라미터 제한 회피) + const ITEM_CHUNK_SIZE = 1000; + for (let i = 0; i < allItemsToInsert.length; i += ITEM_CHUNK_SIZE) { + const chunk = allItemsToInsert.slice(i, i + ITEM_CHUNK_SIZE); + await tx.insert(prItems).values(chunk); + } + + return { processedCount: rfqRecords.length }; + }); + + debugSuccess('ECC 데이터 일괄 처리 완료', { + processedCount: result.processedCount, + }); + + return { + success: true, + message: `${result.processedCount}개의 RFQ 데이터가 성공적으로 처리되었습니다.`, + processedCount: result.processedCount, + }; + } catch (error) { + debugError('ECC 데이터 처리 중 오류 발생', error); + return { + success: false, + message: + error instanceof Error + ? error.message + : '알 수 없는 오류가 발생했습니다.', + processedCount: 0, + }; + } +} + +/** + * ECC 데이터 유효성 검증 + */ +export function validateECCRfqData( + eccHeaders: ECCBidHeader[], + eccItems: ECCBidItem[] +): { isValid: boolean; errors: string[] } { + const errors: string[] = []; + + // 헤더 데이터 검증 + for (const header of eccHeaders) { + if (!header.ANFNR) { + errors.push(`필수 필드 누락: ANFNR (Bidding/RFQ Number)`); + } + if (!header.ZBSART) { + errors.push( + `필수 필드 누락: ZBSART (Bidding Type) - ANFNR: ${header.ANFNR}` + ); + } + } + + // 아이템 데이터 검증 + for (const item of eccItems) { + if (!item.ANFNR) { + errors.push( + `필수 필드 누락: ANFNR (Bidding/RFQ Number) - Item: ${item.ANFPS}` + ); + } + if (!item.ANFPS) { + errors.push(`필수 필드 누락: ANFPS (Item Number) - ANFNR: ${item.ANFNR}`); + } + if (!item.BANFN) { + errors.push( + `필수 필드 누락: BANFN (Purchase Requisition Number) - ANFNR: ${item.ANFNR}, ANFPS: ${item.ANFPS}` + ); + } + if (!item.BANPO) { + errors.push( + `필수 필드 누락: BANPO (Item Number of Purchase Requisition) - ANFNR: ${item.ANFNR}, ANFPS: ${item.ANFPS}` + ); + } + } + + // 헤더와 아이템 간의 관계 검증 + const headerAnfnrs = new Set(eccHeaders.map((h) => h.ANFNR)); + const itemAnfnrs = new Set(eccItems.map((i) => i.ANFNR)); + + for (const anfnr of itemAnfnrs) { + if (!headerAnfnrs.has(anfnr)) { + errors.push(`아이템의 ANFNR이 헤더에 존재하지 않음: ${anfnr}`); + } + } + + return { + isValid: errors.length === 0, + errors, + }; +} diff --git a/lib/users/knox-service.ts b/lib/users/knox-service.ts index d5453072..d6755022 100644 --- a/lib/users/knox-service.ts +++ b/lib/users/knox-service.ts @@ -4,6 +4,8 @@ import { unstable_cache } from "next/cache"; import db from "@/db/db"; import { organization } from "@/db/schema/knox/organization"; import { eq, and, asc } from "drizzle-orm"; +import { users } from "@/db/schema/users"; +import { employee } from "@/db/schema/knox/employee"; // 조직 트리 노드 타입 export interface DepartmentNode { @@ -375,3 +377,38 @@ export async function getCurrentCompanyInfo(): Promise<{ code: string; name: str name: "삼성중공업" }; } + +// 사번으로 user Id 찾기 +export async function findUserIdByEmployeeNumber(employeeNumber: string): Promise<number | null> { + + try { + // 1. 사번 기준으로 email 찾기 + const userEmail = await db + .select({ email: employee.emailAddress }) + .from(employee) + .where(eq(employee.employeeNumber, employeeNumber)) + .limit(1); + + if (userEmail.length === 0) { + console.error('사번에 해당하는 이메일 찾기 실패', { employeeNumber }); + return null; + } + + // 2. 이메일 기준으로 userId 찾기 + const userId = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, userEmail[0].email)); + + if (userId.length === 0) { + console.error('이메일에 해당하는 사용자 찾기 실패', { email: userEmail[0].email }); + return null; + } + + return userId[0].id; + + } catch (error) { + console.error('사번에 해당하는 이메일 찾기 실패', { employeeNumber, error }); + return null; + } +}
\ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6d1047e8..ff8b68cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "@tiptap/extension-link": "^2.23.1", "@tiptap/extension-list-item": "^2.23.1", "@tiptap/extension-ordered-list": "^2.23.1", + "@tiptap/extension-placeholder": "^2.23.1", "@tiptap/extension-subscript": "^2.23.1", "@tiptap/extension-superscript": "^2.23.1", "@tiptap/extension-table": "^2.23.1", @@ -163,6 +164,7 @@ "swr": "^2.3.3", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "tiptap-extension-resize-image": "^1.3.0", "tmp-promise": "^3.0.3", "ua-parser-js": "^2.0.4", "uuid": "^11.0.5", @@ -5414,6 +5416,20 @@ "@tiptap/core": "^2.7.0" } }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.26.1.tgz", + "integrity": "sha512-MBlqbkd+63btY7Qu+SqrXvWjPwooGZDsLTtl7jp52BczBl61cq9yygglt9XpM11TFMBdySgdLHBrLtQ0B7fBlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@tiptap/extension-strike": { "version": "2.26.1", "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.26.1.tgz", @@ -16430,6 +16446,17 @@ "@popperjs/core": "^2.9.0" } }, + "node_modules/tiptap-extension-resize-image": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiptap-extension-resize-image/-/tiptap-extension-resize-image-1.3.0.tgz", + "integrity": "sha512-5QfDhDBGEEyuoZqhek85KmrIkc9CNSAe5I0a2tx/wmAJ9ytiQ/w0OtrFRIpJDMnj3fNxUu+n5OLKnBFwXRZV9Q==", + "license": "MIT", + "peerDependencies": { + "@tiptap/core": "^2.0.0 || ^3.0.0", + "@tiptap/extension-image": "^2.0.0 || ^3.0.0", + "@tiptap/pm": "^2.0.0 || ^3.0.0" + } + }, "node_modules/tmp": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz", diff --git a/package.json b/package.json index 7a7955e5..aa51b6ea 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@tiptap/extension-link": "^2.23.1", "@tiptap/extension-list-item": "^2.23.1", "@tiptap/extension-ordered-list": "^2.23.1", + "@tiptap/extension-placeholder": "^2.23.1", "@tiptap/extension-subscript": "^2.23.1", "@tiptap/extension-superscript": "^2.23.1", "@tiptap/extension-table": "^2.23.1", @@ -165,6 +166,7 @@ "swr": "^2.3.3", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "tiptap-extension-resize-image": "^1.3.0", "tmp-promise": "^3.0.3", "ua-parser-js": "^2.0.4", "uuid": "^11.0.5", @@ -193,7 +195,7 @@ "tsx": "^4.19.2", "typescript": "^5.7.2" }, - "overrides": { + "overrides": { "rimraf": "3.0.2" } } diff --git a/public/wsdl/IF_ECC_EVCP_PCR.wsdl b/public/wsdl/IF_ECC_EVCP_PCR.wsdl index d5b10bfc..fa229dac 100644 --- a/public/wsdl/IF_ECC_EVCP_PCR.wsdl +++ b/public/wsdl/IF_ECC_EVCP_PCR.wsdl @@ -28,13 +28,13 @@ <!-- SEQ:1, Table:ZMM_PCR, Field:PCR_REQ, M/O:M, Type:CHAR, Size:10, Description:PCR 요청번호 --> <xs:element name="PCR_REQ" type="xs:string"/> <!-- SEQ:2, Table:ZMM_PCR, Field:PCR_REQ_SEQ, M/O:M, Type:NUMC, Size:5, Description:PCR 요청순번 --> - <xs:element name="PCR_REQ_SEQ" type="xs:integer"/> + <xs:element name="PCR_REQ_SEQ" type="xs:string"/> <!-- SEQ:3, Table:ZMM_PCR, Field:PCR_REQ_DATE, M/O:M, Type:DATS, Size:8, Description:PCR 요청일자 --> <xs:element name="PCR_REQ_DATE" type="xs:string"/> <!-- SEQ:4, Table:ZMM_PCR, Field:EBELN, M/O:M, Type:CHAR, Size:10, Description:구매오더 --> <xs:element name="EBELN" type="xs:string"/> <!-- SEQ:5, Table:ZMM_PCR, Field:EBELP, M/O:M, Type:NUMC, Size:5, Description:구매오더 품번 --> - <xs:element name="EBELP" type="xs:integer"/> + <xs:element name="EBELP" type="xs:string"/> <!-- SEQ:6, Table:ZMM_PCR, Field:PCR_TYPE, M/O:M, Type:CHAR, Size:2, Description:물량/Spec 변경 Type : Q, W, S, QW --> <xs:element name="PCR_TYPE" type="xs:string"/> <!-- SEQ:7, Table:ZMM_PCR, Field:PSPID, M/O:, Type:CHAR, Size:24, Description:프로젝트 --> @@ -42,7 +42,7 @@ <!-- SEQ:8, Table:ZMM_PCR, Field:BANFN, M/O:M, Type:CHAR, Size:10, Description:구매요청 --> <xs:element name="BANFN" type="xs:string"/> <!-- SEQ:9, Table:ZMM_PCR, Field:BNFPO, M/O:M, Type:NUMC, Size:5, Description:구매요청 품번 --> - <xs:element name="BNFPO" type="xs:integer"/> + <xs:element name="BNFPO" type="xs:string"/> <!-- SEQ:10, Table:ZMM_PCR, Field:MATNR, M/O:, Type:CHAR, Size:18, Description:자재번호 --> <xs:element name="MATNR" type="xs:string" minOccurs="0"/> <!-- SEQ:11, Table:ZMM_PCR, Field:MAKTX, M/O:, Type:CHAR, Size:40, Description:자재명 --> @@ -52,25 +52,25 @@ <!-- SEQ:13, Table:ZMM_PCR, Field:ZSPEC_NUM, M/O:, Type:CHAR, Size:25, Description:POS --> <xs:element name="ZSPEC_NUM" type="xs:string" minOccurs="0"/> <!-- SEQ:14, Table:ZMM_PCR, Field:QTY_B, M/O:, Type:QUAN, Size:13,3, Description:변경 전 수량 --> - <xs:element name="QTY_B" type="xs:decimal" minOccurs="0"/> + <xs:element name="QTY_B" type="xs:string" minOccurs="0"/> <!-- SEQ:15, Table:ZMM_PCR, Field:QTY_A, M/O:, Type:QUAN, Size:13,3, Description:변경 후 수량 --> - <xs:element name="QTY_A" type="xs:decimal" minOccurs="0"/> + <xs:element name="QTY_A" type="xs:string" minOccurs="0"/> <!-- SEQ:16, Table:ZMM_PCR, Field:MEINS, M/O:, Type:UNIT, Size:3, Description:단위 --> <xs:element name="MEINS" type="xs:string" minOccurs="0"/> <!-- SEQ:17, Table:ZMM_PCR, Field:T_WEIGHT_B, M/O:, Type:QUAN, Size:13,3, Description:변경 전 Total 중량 --> - <xs:element name="T_WEIGHT_B" type="xs:decimal" minOccurs="0"/> + <xs:element name="T_WEIGHT_B" type="xs:string" minOccurs="0"/> <!-- SEQ:18, Table:ZMM_PCR, Field:T_WEIGHT_A, M/O:, Type:QUAN, Size:13,3, Description:변경 후 Total 중량 --> - <xs:element name="T_WEIGHT_A" type="xs:decimal" minOccurs="0"/> + <xs:element name="T_WEIGHT_A" type="xs:string" minOccurs="0"/> <!-- SEQ:19, Table:ZMM_PCR, Field:MEINS_W, M/O:, Type:UNIT, Size:3, Description:중량 단위 --> <xs:element name="MEINS_W" type="xs:string" minOccurs="0"/> <!-- SEQ:20, Table:ZMM_PCR, Field:S_WEIGHT_B, M/O:, Type:QUAN, Size:13,3, Description:변경 전 사급 중량 --> - <xs:element name="S_WEIGHT_B" type="xs:decimal" minOccurs="0"/> + <xs:element name="S_WEIGHT_B" type="xs:string" minOccurs="0"/> <!-- SEQ:21, Table:ZMM_PCR, Field:S_WEIGHT_A, M/O:, Type:QUAN, Size:13,3, Description:변경 후 사급 중량 --> - <xs:element name="S_WEIGHT_A" type="xs:decimal" minOccurs="0"/> + <xs:element name="S_WEIGHT_A" type="xs:string" minOccurs="0"/> <!-- SEQ:22, Table:ZMM_PCR, Field:C_WEIGHT_B, M/O:, Type:QUAN, Size:13,3, Description:변경 전 도급 중량 --> - <xs:element name="C_WEIGHT_B" type="xs:decimal" minOccurs="0"/> + <xs:element name="C_WEIGHT_B" type="xs:string" minOccurs="0"/> <!-- SEQ:23, Table:ZMM_PCR, Field:C_WEIGHT_A, M/O:, Type:QUAN, Size:13,3, Description:변경 후 도급 중량 --> - <xs:element name="C_WEIGHT_A" type="xs:decimal" minOccurs="0"/> + <xs:element name="C_WEIGHT_A" type="xs:string" minOccurs="0"/> <!-- SEQ:24, Table:ZMM_PCR, Field:ZACC_DT, M/O:, Type:DATS, Size:8, Description:구매담당자 PR 접수일 --> <xs:element name="ZACC_DT" type="xs:string" minOccurs="0"/> <!-- SEQ:25, Table:ZMM_PCR, Field:ERDAT, M/O:, Type:DATS, Size:8, Description:물량 변경일 --> @@ -94,11 +94,11 @@ <!-- SEQ:34, Table:ZMM_PCR, Field:WAERS, M/O:M, Type:CUKY, Size:5, Description:PO 통화 --> <xs:element name="WAERS" type="xs:string"/> <!-- SEQ:35, Table:ZMM_PCR, Field:NETPR, M/O:M, Type:CURR, Size:13,2, Description:PO 단가 --> - <xs:element name="NETPR" type="xs:decimal"/> + <xs:element name="NETPR" type="xs:string"/> <!-- SEQ:36, Table:ZMM_PCR, Field:PEINH, M/O:, Type:DEC, Size:5, Description:Price Unit, 수량에 대한 PER 당 단가 --> - <xs:element name="PEINH" type="xs:decimal" minOccurs="0"/> + <xs:element name="PEINH" type="xs:string" minOccurs="0"/> <!-- SEQ:37, Table:ZMM_PCR, Field:NETWR, M/O:M, Type:CURR, Size:13,2, Description:PO 금액 --> - <xs:element name="NETWR" type="xs:decimal"/> + <xs:element name="NETWR" type="xs:string"/> <!-- SEQ:38, Table:ZMM_PCR, Field:POSID, M/O:, Type:CHAR, Size:24, Description:WBS --> <xs:element name="POSID" type="xs:string" minOccurs="0"/> <!-- SEQ:39, Table:ZMM_PCR, Field:EKGRP, M/O:, Type:CHAR, Size:3, Description:구매그룹 --> @@ -126,17 +126,24 @@ </xs:sequence> </xs:complexType> - <!-- 1.3) 수신측 응답 구조 --> + <!-- 1.4) 수신측 응답 구조 (최상위 타입 정의) --> <xs:complexType name="IF_ECC_EVCP_PCRRes"> <xs:sequence> + <xs:element name="ZMM_RT" type="tns:ZMM_RT" minOccurs="1" maxOccurs="1"/> + </xs:sequence> + </xs:complexType> + + <!-- 1.5) 수신측 응답 구조 복합타입 (ZMM_RT) --> + <xs:complexType name="ZMM_RT"> + <xs:sequence> <!-- SEQ:50, Table:ZMM_RT (수신측 응답), Field:PCR_REQ, M/O:M, Type:CHAR, Size:10, Description:PCR 요청번호 --> <xs:element name="PCR_REQ" type="xs:string"/> <!-- SEQ:51, Table:ZMM_RT (수신측 응답), Field:PCR_REQ_SEQ, M/O:M, Type:NUMC, Size:5, Description:PCR 요청순번 --> - <xs:element name="PCR_REQ_SEQ" type="xs:integer"/> + <xs:element name="PCR_REQ_SEQ" type="xs:string"/> <!-- SEQ:52, Table:ZMM_RT (수신측 응답), Field:EBELN, M/O:M, Type:CHAR, Size:10, Description:구매오더 --> <xs:element name="EBELN" type="xs:string"/> <!-- SEQ:53, Table:ZMM_RT (수신측 응답), Field:EBELP, M/O:M, Type:NUMC, Size:5, Description:구매오더 품번 --> - <xs:element name="EBELP" type="xs:integer"/> + <xs:element name="EBELP" type="xs:string"/> <!-- SEQ:54, Table:ZMM_RT (수신측 응답), Field:MSGTY, M/O:, Type:CHAR, Size:1, Description:Message Type --> <xs:element name="MSGTY" type="xs:string" minOccurs="0"/> <!-- SEQ:55, Table:ZMM_RT (수신측 응답), Field:MSGTXT, M/O:, Type:CHAR, Size:100, Description:Message Text --> diff --git a/public/wsdl/IF_ECC_EVCP_PO_INFORMATION.wsdl b/public/wsdl/IF_ECC_EVCP_PO_INFORMATION.wsdl index f5be8f32..38b5f43d 100644 --- a/public/wsdl/IF_ECC_EVCP_PO_INFORMATION.wsdl +++ b/public/wsdl/IF_ECC_EVCP_PO_INFORMATION.wsdl @@ -20,16 +20,15 @@ <xs:sequence> <!-- Header 레코드 집합 --> <xs:element name="ZMM_HD" type="tns:ZMM_HD" maxOccurs="unbounded" minOccurs="0"/> - <!-- 지불방법 레코드 집합 --> - <xs:element name="ZMM_HD_ZMM_PAY" type="tns:ZMM_HD_ZMM_PAY" maxOccurs="unbounded" minOccurs="0"/> - <!-- PO Detail 레코드 집합 --> - <xs:element name="ZMM_HD_ZMM_DT" type="tns:ZMM_HD_ZMM_DT" maxOccurs="unbounded" minOccurs="0"/> - <!-- PO Detail 의 계정관련 레코드 집합 --> - <xs:element name="ZMM_HD_ZMM_DT_ZMM_KN" type="tns:ZMM_HD_ZMM_DT_ZMM_KN" maxOccurs="unbounded" minOccurs="0"/> - <!-- PO Note 1 --> - <xs:element name="ZMM_HD_ZMM_NOTE" type="tns:ZMM_HD_ZMM_NOTE" maxOccurs="unbounded" minOccurs="0"/> - <!-- PO Note 2 --> - <xs:element name="ZMM_HD_ZMM_NOTE2" type="tns:ZMM_HD_ZMM_NOTE2" maxOccurs="unbounded" minOccurs="0"/> + <!-- 지불방법 레코드 집합 (ZMM_HD의 하위 테이블) --> + <xs:element name="ZMM_PAY" type="tns:ZMM_PAY" maxOccurs="unbounded" minOccurs="0"/> + <!-- PO Detail 레코드 집합 (ZMM_HD의 하위 테이블) --> + <xs:element name="ZMM_DT" type="tns:ZMM_DT" maxOccurs="unbounded" minOccurs="0"/> + <!-- KN 은 DT의 하위 테이블이므로 생략 --> + <!-- PO Note 1 (ZMM_HD의 하위 테이블) --> + <xs:element name="ZMM_NOTE" type="tns:ZMM_NOTE" maxOccurs="unbounded" minOccurs="0"/> + <!-- PO Note 2 (ZMM_HD의 하위 테이블) --> + <xs:element name="ZMM_NOTE2" type="tns:ZMM_NOTE2" maxOccurs="unbounded" minOccurs="0"/> </xs:sequence> </xs:complexType> @@ -59,7 +58,7 @@ <!-- SEQ:11, Table:ZMM_HD, Field:EKGRP, M/O:M, Type:CHAR, Size:3, Description:구매그룹코드 --> <xs:element name="EKGRP" type="xs:string"/> <!-- SEQ:12, Table:ZMM_HD, Field:WKURS, M/O:M, Type:DEC, Size:9,5, Description:환율 --> - <xs:element name="WKURS" type="xs:decimal"/> + <xs:element name="WKURS" type="xs:string"/> <!-- SEQ:13, Table:ZMM_HD, Field:BEDAT, M/O:M, Type:DATS, Size:8, Description:구매증빙일자 --> <xs:element name="BEDAT" type="xs:string"/> <!-- SEQ:14, Table:ZMM_HD, Field:INCO1, M/O:M, Type:CHAR, Size:3, Description:인도조건코드 --> @@ -71,9 +70,9 @@ <!-- SEQ:17, Table:ZMM_HD, Field:ZIND_CD, M/O:M, Type:CHAR, Size:2, Description:증감코드 --> <xs:element name="ZIND_CD" type="xs:string"/> <!-- SEQ:18, Table:ZMM_HD, Field:ZDAMT_DD_SUBRT, M/O:M, Type:DEC, Size:6,2, Description:지체상금일일공제율 --> - <xs:element name="ZDAMT_DD_SUBRT" type="xs:decimal"/> + <xs:element name="ZDAMT_DD_SUBRT" type="xs:string"/> <!-- SEQ:19, Table:ZMM_HD, Field:ZMAX_SUBRT, M/O:M, Type:DEC, Size:6,2, Description:최대공제율 --> - <xs:element name="ZMAX_SUBRT" type="xs:decimal"/> + <xs:element name="ZMAX_SUBRT" type="xs:string"/> <!-- SEQ:20, Table:ZMM_HD, Field:ZCNRT_GRNT_CD, M/O:M, Type:CHAR, Size:1, Description:계약보증코드 --> <xs:element name="ZCNRT_GRNT_CD" type="xs:string"/> <!-- SEQ:21, Table:ZMM_HD, Field:ZDFCT_GRNT_CD, M/O:M, Type:CHAR, Size:1, Description:하자보증코드 --> @@ -83,13 +82,13 @@ <!-- SEQ:23, Table:ZMM_HD, Field:ZPAMT_YN, M/O:M, Type:CHAR, Size:1, Description:선급금여부 --> <xs:element name="ZPAMT_YN" type="xs:string"/> <!-- SEQ:24, Table:ZMM_HD, Field:ZBGT_AMT, M/O:M, Type:CURR, Size:17,2, Description:예산금액, ZBTG_CURR --> - <xs:element name="ZBGT_AMT" type="xs:decimal"/> + <xs:element name="ZBGT_AMT" type="xs:string"/> <!-- SEQ:25, Table:ZMM_HD, Field:ZBGT_CURR, M/O:M, Type:CUKY, Size:3, Description:예산금액 통화키 --> <xs:element name="ZBGT_CURR" type="xs:string"/> <!-- SEQ:26, Table:ZMM_HD, Field:ZPO_AMT, M/O:M, Type:CURR, Size:17,2, Description:발주금액 --> - <xs:element name="ZPO_AMT" type="xs:decimal"/> + <xs:element name="ZPO_AMT" type="xs:string"/> <!-- SEQ:27, Table:ZMM_HD, Field:ZPO_AMT_KRW, M/O:M, Type:CURR, Size:17,2, Description:발주금액 KRW --> - <xs:element name="ZPO_AMT_KRW" type="xs:decimal"/> + <xs:element name="ZPO_AMT_KRW" type="xs:string"/> <!-- SEQ:28, Table:ZMM_HD, Field:ZPO_CURR, M/O:M, Type:CUKY, Size:5, Description:통화키 --> <xs:element name="ZPO_CURR" type="xs:string"/> <!-- SEQ:29, Table:ZMM_HD, Field:ZCHG_PO_DT, M/O:M, Type:DATS, Size:8, Description:변경발주일자 --> @@ -117,15 +116,15 @@ <!-- SEQ:40, Table:ZMM_HD, Field:ZPO_TRANS_CANC, M/O:M, Type:DATS, Size:1, Description:전송여부지시자 --> <xs:element name="ZPO_TRANS_CANC" type="xs:string"/> <!-- SEQ:41, Table:ZMM_HD, Field:ZVST_TMS, M/O:M, Type:NUMC, Size:9, Description:방문횟수 --> - <xs:element name="ZVST_TMS" type="xs:integer"/> + <xs:element name="ZVST_TMS" type="xs:string"/> <!-- SEQ:42, Table:ZMM_HD, Field:ZSVC_WK_PRD, M/O:M, Type:NUMC, Size:9, Description:SE작업일수 --> - <xs:element name="ZSVC_WK_PRD" type="xs:integer"/> + <xs:element name="ZSVC_WK_PRD" type="xs:string"/> <!-- SEQ:43, Table:ZMM_HD, Field:ZDT_EXCS_AMT, M/O:M, Type:CURR, Size:17,2, Description:일초과금액1 --> - <xs:element name="ZDT_EXCS_AMT" type="xs:decimal"/> + <xs:element name="ZDT_EXCS_AMT" type="xs:string"/> <!-- SEQ:44, Table:ZMM_HD, Field:ZDT_EXCS_AMT2, M/O:M, Type:CURR, Size:17,2, Description:일초과금액2 --> - <xs:element name="ZDT_EXCS_AMT2" type="xs:decimal"/> + <xs:element name="ZDT_EXCS_AMT2" type="xs:string"/> <!-- SEQ:45, Table:ZMM_HD, Field:ZDT_EXCS_AMT3, M/O:M, Type:CURR, Size:17,2, Description:일초과금액3 --> - <xs:element name="ZDT_EXCS_AMT3" type="xs:decimal"/> + <xs:element name="ZDT_EXCS_AMT3" type="xs:string"/> <!-- SEQ:46, Table:ZMM_HD, Field:ZSVC_CNRT_CUR, M/O:M, Type:CUKY, Size:5, Description:SE계약통화 --> <xs:element name="ZSVC_CNRT_CUR" type="xs:string"/> <!-- SEQ:47, Table:ZMM_HD, Field:ZPAY_GB, M/O:M, Type:CHAR, Size:1, Description:기타비용처리구분 --> @@ -139,7 +138,7 @@ <!-- SEQ:51, Table:ZMM_HD, Field:ZTITLE, M/O:M, Type:CHAR, Size:90, Description:발주제목 --> <xs:element name="ZTITLE" type="xs:string"/> <!-- SEQ:52, Table:ZMM_HD, Field:ZPO_VER, M/O:M, Type:NUMC, Size:2, Description:발주버전 --> - <xs:element name="ZPO_VER" type="xs:integer"/> + <xs:element name="ZPO_VER" type="xs:string"/> <!-- SEQ:53, Table:ZMM_HD, Field:ITEM_CATEGORY, M/O:M, Type:CHAR, Size:2, Description:선물환 Item Category --> <xs:element name="ITEM_CATEGORY" type="xs:string"/> <!-- SEQ:54, Table:ZMM_HD, Field:LTEXT, M/O:M, Type:CHAR, Size:60, Description:선물환 Item Category 명 --> @@ -166,40 +165,39 @@ <xs:element name="ETC_9" type="xs:string"/> <!-- SEQ:65, Table:ZMM_HD, Field:ETC_10, M/O:M, Type:CHAR, Size:100, Description:확장10 --> <xs:element name="ETC_10" type="xs:string"/> - <!-- SEQ:66, Table:ZMM_HD, Field:ZDLV_PRICE_T, M/O:, Type:CHAR, Size:1, Description:납품대금연동제대상여부 -(Y:대상, N:미대상, 공백:미해당) --> + <!-- SEQ:66, Table:ZMM_HD, Field:ZDLV_PRICE_T, M/O:, Type:CHAR, Size:1, Description:납품대금연동제대상여부 (Y:대상, N:미대상, 공백:미해당) --> <xs:element name="ZDLV_PRICE_T" type="xs:string" minOccurs="0"/> <!-- SEQ:67, Table:ZMM_HD, Field:ZWEBELN, M/O:, Type:CHAR, Size:10, Description:서면계약번호 --> <xs:element name="ZWEBELN" type="xs:string" minOccurs="0"/> <!-- SEQ:68, Table:ZMM_HD, Field:ZVER_NO, M/O:, Type:NUMC, Size:3, Description:서면계약차수 --> - <xs:element name="ZVER_NO" type="xs:integer" minOccurs="0"/> + <xs:element name="ZVER_NO" type="xs:string" minOccurs="0"/> </xs:sequence> </xs:complexType> <!-- 1.3) ZMM_HD/ZMM_PAY 테이블 구조 (SEQ 69~73) --> - <xs:complexType name="ZMM_HD_ZMM_PAY"> + <xs:complexType name="ZMM_PAY"> <xs:sequence> <!-- SEQ:69, Table:ZMM_HD/ZMM_PAY, Field:ZPAYSEQ, M/O:M, Type:CHAR, Size:2, Description:선급금차수 --> <xs:element name="ZPAYSEQ" type="xs:string"/> <!-- SEQ:70, Table:ZMM_HD/ZMM_PAY, Field:ZADVTYP, M/O:M, Type:CHAR, Size:1, Description:선급금타입 --> <xs:element name="ZADVTYP" type="xs:string"/> <!-- SEQ:71, Table:ZMM_HD/ZMM_PAY, Field:ZDWPRT, M/O:M, Type:NUMC, Size:3, Description:선급금비율 --> - <xs:element name="ZDWPRT" type="xs:integer"/> + <xs:element name="ZDWPRT" type="xs:string"/> <!-- SEQ:72, Table:ZMM_HD/ZMM_PAY, Field:ZDWPAMT, M/O:M, Type:CURR, Size:17,2, Description:선급금 --> - <xs:element name="ZDWPAMT" type="xs:decimal"/> + <xs:element name="ZDWPAMT" type="xs:string"/> <!-- SEQ:73, Table:ZMM_HD/ZMM_PAY, Field:ZDWPDAT, M/O:M, Type:DATS, Size:8, Description:지불계획일자 --> <xs:element name="ZDWPDAT" type="xs:string"/> </xs:sequence> </xs:complexType> <!-- 1.4) ZMM_HD/ZMM_DT 테이블 구조 (SEQ 74~165) --> - <xs:complexType name="ZMM_HD_ZMM_DT"> + <xs:complexType name="ZMM_DT"> <xs:sequence> <!-- SEQ:74, Table:ZMM_HD/ZMM_DT, Field:EBELP, M/O:M, Type:NUMC, Size:5, Description:구매오더품목번호 --> - <xs:element name="EBELP" type="xs:integer"/> - <!-- SEQ:5, Table:ZMM_HD, Field:LOEKZ, M/O:M, Type:CHAR, Size:1, Description:구매문서삭제지시자 --> + <xs:element name="EBELP" type="xs:string"/> + <!-- SEQ:75, Table:ZMM_HD/ZMM_DT, Field:LOEKZ, M/O:M, Type:CHAR, Size:1, Description:구매문서삭제지시자 --> <xs:element name="LOEKZ" type="xs:string"/> - <!-- SEQ:6, Table:ZMM_HD, Field:AEDAT, M/O:M, Type:DATS, Size:8, Description:생성일자 --> + <!-- SEQ:76, Table:ZMM_HD/ZMM_DT, Field:AEDAT, M/O:M, Type:DATS, Size:8, Description:생성일자 --> <xs:element name="AEDAT" type="xs:string"/> <!-- SEQ:77, Table:ZMM_HD/ZMM_DT, Field:MAKTX, M/O:M, Type:CHAR, Size:120, Description:자재내역 --> <xs:element name="MAKTX" type="xs:string"/> @@ -214,17 +212,17 @@ <!-- SEQ:82, Table:ZMM_HD/ZMM_DT, Field:BEDNR, M/O:M, Type:CHAR, Size:10, Description:요청추적번호 --> <xs:element name="BEDNR" type="xs:string"/> <!-- SEQ:83, Table:ZMM_HD/ZMM_DT, Field:MENGE, M/O:M, Type:QUAN, Size:13,3, Description:구매오더수량 --> - <xs:element name="MENGE" type="xs:decimal"/> + <xs:element name="MENGE" type="xs:string"/> <!-- SEQ:84, Table:ZMM_HD/ZMM_DT, Field:NETPR, M/O:M, Type:CURR, Size:17,2, Description:구매단가 --> - <xs:element name="NETPR" type="xs:decimal"/> + <xs:element name="NETPR" type="xs:string"/> <!-- SEQ:85, Table:ZMM_HD/ZMM_DT, Field:PEINH, M/O:M, Type:DEC, Size:5, Description:가격단위값 --> - <xs:element name="PEINH" type="xs:decimal"/> + <xs:element name="PEINH" type="xs:string"/> <!-- SEQ:86, Table:ZMM_HD/ZMM_DT, Field:NETWR, M/O:M, Type:CURR, Size:17,2, Description:오더정가 --> - <xs:element name="NETWR" type="xs:decimal"/> + <xs:element name="NETWR" type="xs:string"/> <!-- SEQ:87, Table:ZMM_HD/ZMM_DT, Field:BRTWR, M/O:M, Type:CURR, Size:17,2, Description:오더총액 --> - <xs:element name="BRTWR" type="xs:decimal"/> + <xs:element name="BRTWR" type="xs:string"/> <!-- SEQ:88, Table:ZMM_HD/ZMM_DT, Field:WEBAZ, M/O:M, Type:DEC, Size:3, Description:입고소요일수 --> - <xs:element name="WEBAZ" type="xs:decimal"/> + <xs:element name="WEBAZ" type="xs:string"/> <!-- SEQ:89, Table:ZMM_HD/ZMM_DT, Field:MWSKZ, M/O:M, Type:CHAR, Size:2, Description:매출부가가치세코드 --> <xs:element name="MWSKZ" type="xs:string"/> <!-- SEQ:90, Table:ZMM_HD/ZMM_DT, Field:INSMK, M/O:M, Type:CHAR, Size:1, Description:재고유형 --> @@ -246,19 +244,19 @@ <!-- SEQ:98, Table:ZMM_HD/ZMM_DT, Field:KNTTP, M/O:M, Type:CHAR, Size:1, Description:계정지정범주 --> <xs:element name="KNTTP" type="xs:string"/> <!-- SEQ:99, Table:ZMM_HD/ZMM_DT, Field:NTGEW, M/O:M, Type:QUAN, Size:13,3, Description:순중량 --> - <xs:element name="NTGEW" type="xs:decimal"/> + <xs:element name="NTGEW" type="xs:string"/> <!-- SEQ:100, Table:ZMM_HD/ZMM_DT, Field:GEWEI, M/O:M, Type:UNIT, Size:3, Description:중량단위 --> <xs:element name="GEWEI" type="xs:string"/> <!-- SEQ:101, Table:ZMM_HD/ZMM_DT, Field:BRGEW, M/O:M, Type:QUAN, Size:15,3, Description:총중량 --> - <xs:element name="BRGEW" type="xs:decimal"/> + <xs:element name="BRGEW" type="xs:string"/> <!-- SEQ:102, Table:ZMM_HD/ZMM_DT, Field:VOLUM, M/O:M, Type:QUAN, Size:15,3, Description:볼륨 --> - <xs:element name="VOLUM" type="xs:decimal"/> + <xs:element name="VOLUM" type="xs:string"/> <!-- SEQ:103, Table:ZMM_HD/ZMM_DT, Field:VOLEH, M/O:M, Type:UNIT, Size:3, Description:볼륨단위 --> <xs:element name="VOLEH" type="xs:string"/> <!-- SEQ:104, Table:ZMM_HD/ZMM_DT, Field:BANFN, M/O:M, Type:CHAR, Size:10, Description:구매요청번호 --> <xs:element name="BANFN" type="xs:string"/> <!-- SEQ:105, Table:ZMM_HD/ZMM_DT, Field:BNFPO, M/O:M, Type:NUMC, Size:5, Description:구매요청품목번호 --> - <xs:element name="BNFPO" type="xs:integer"/> + <xs:element name="BNFPO" type="xs:string"/> <!-- SEQ:106, Table:ZMM_HD/ZMM_DT, Field:UPTYP, M/O:M, Type:CHAR, Size:1, Description:하위품목범주 --> <xs:element name="UPTYP" type="xs:string"/> <!-- SEQ:107, Table:ZMM_HD/ZMM_DT, Field:UPVOR, M/O:M, Type:CHAR, Size:1, Description:하위품목존재여부 --> @@ -271,18 +269,18 @@ <xs:element name="ZDST_CD" type="xs:string"/> <!-- SEQ:111, Table:ZMM_HD/ZMM_DT, Field:ZRCV_DT, M/O:M, Type:DATS, Size:8, Description:구매접수일자 --> <xs:element name="ZRCV_DT" type="xs:string"/> - <!-- SEQ:50, Table:ZMM_HD, Field:ZCON_NO, M/O:M, Type:CHAR, Size:10, Description:구매통합번호 --> + <!-- SEQ:112, Table:ZMM_HD/ZMM_DT, Field:ZCON_NO, M/O:M, Type:CHAR, Size:10, Description:구매통합번호 --> <xs:element name="ZCON_NO" type="xs:string"/> <!-- SEQ:113, Table:ZMM_HD/ZMM_DT, Field:ZCON_IND, M/O:M, Type:CHAR, Size:1, Description:시리즈구분 --> <xs:element name="ZCON_IND" type="xs:string"/> <!-- SEQ:114, Table:ZMM_HD/ZMM_DT, Field:ZCHAR_CD, M/O:M, Type:CHAR, Size:1, Description:물성코드,풍력 일련번호 처리여부 --> <xs:element name="ZCHAR_CD" type="xs:string"/> <!-- SEQ:115, Table:ZMM_HD/ZMM_DT, Field:ZMAT_AREA, M/O:M, Type:QUAN, Size:13,3, Description:자재면적 --> - <xs:element name="ZMAT_AREA" type="xs:decimal"/> + <xs:element name="ZMAT_AREA" type="xs:string"/> <!-- SEQ:116, Table:ZMM_HD/ZMM_DT, Field:ZSZ, M/O:M, Type:CHAR, Size:50, Description:품목사이즈 --> <xs:element name="ZSZ" type="xs:string"/> <!-- SEQ:117, Table:ZMM_HD/ZMM_DT, Field:ZAF_ECAL_AMT, M/O:M, Type:CURR, Size:17,2, Description:사후정산금액(참고: NETWR), ZPO_CURR --> - <xs:element name="ZAF_ECAL_AMT" type="xs:decimal"/> + <xs:element name="ZAF_ECAL_AMT" type="xs:string"/> <!-- SEQ:118, Table:ZMM_HD/ZMM_DT, Field:ZPLN_ST_DT, M/O:M, Type:DATS, Size:8, Description:예정시작일자 --> <xs:element name="ZPLN_ST_DT" type="xs:string"/> <!-- SEQ:119, Table:ZMM_HD/ZMM_DT, Field:ZPLN_ED_DT, M/O:M, Type:DATS, Size:8, Description:예정종료일자 --> @@ -290,61 +288,61 @@ <!-- SEQ:49, Table:ZMM_HD, Field:PSPID, M/O:M, Type:CHAR, Size:24, Description:프로젝트 번호 --> <xs:element name="PSPID" type="xs:string"/> <!-- SEQ:121, Table:ZMM_HD/ZMM_DT, Field:ZUSD_BGT, M/O:M, Type:CURR, Size:17,2, Description:미화예산 --> - <xs:element name="ZUSD_BGT" type="xs:decimal"/> + <xs:element name="ZUSD_BGT" type="xs:string"/> <!-- SEQ:122, Table:ZMM_HD/ZMM_DT, Field:ZKRW_BGT, M/O:M, Type:CURR, Size:17,2, Description:원화예산 --> - <xs:element name="ZKRW_BGT" type="xs:decimal"/> + <xs:element name="ZKRW_BGT" type="xs:string"/> <!-- SEQ:123, Table:ZMM_HD/ZMM_DT, Field:ZDLV_CNTLR, M/O:M, Type:CHAR, Size:3, Description:조달담당자코드 --> <xs:element name="ZDLV_CNTLR" type="xs:string"/> <!-- SEQ:124, Table:ZMM_HD/ZMM_DT, Field:ANFNR, M/O:M, Type:CHAR, Size:10, Description:RFQ번호 --> <xs:element name="ANFNR" type="xs:string"/> <!-- SEQ:125, Table:ZMM_HD/ZMM_DT, Field:ANFPS, M/O:M, Type:NUMC, Size:5, Description:RFQ품목번호 --> - <xs:element name="ANFPS" type="xs:integer"/> + <xs:element name="ANFPS" type="xs:string"/> <!-- SEQ:126, Table:ZMM_HD/ZMM_DT, Field:KONNR, M/O:M, Type:CHAR, Size:10, Description:계약번호 --> <xs:element name="KONNR" type="xs:string"/> <!-- SEQ:127, Table:ZMM_HD/ZMM_DT, Field:KTPNR, M/O:M, Type:NUMC, Size:5, Description:계약항목번호 --> - <xs:element name="KTPNR" type="xs:integer"/> + <xs:element name="KTPNR" type="xs:string"/> <!-- SEQ:128, Table:ZMM_HD/ZMM_DT, Field:ZCR_NO, M/O:M, Type:CHAR, Size:40, Description:CR번호 --> <xs:element name="ZCR_NO" type="xs:string"/> <!-- SEQ:129, Table:ZMM_HD/ZMM_DT, Field:ZCR_AMT, M/O:M, Type:CURR, Size:17,2, Description:EXTRA CREDIT 금액 --> - <xs:element name="ZCR_AMT" type="xs:decimal"/> + <xs:element name="ZCR_AMT" type="xs:string"/> <!-- SEQ:130, Table:ZMM_HD/ZMM_DT, Field:ZRT_CUR, M/O:M, Type:CUKY, Size:3, Description:실적통화 --> <xs:element name="ZRT_CUR" type="xs:string"/> <!-- SEQ:131, Table:ZMM_HD/ZMM_DT, Field:ZRT_AMT, M/O:M, Type:CURR, Size:17,2, Description:실적금액, ZRT_CURR --> - <xs:element name="ZRT_AMT" type="xs:decimal"/> + <xs:element name="ZRT_AMT" type="xs:string"/> <!-- SEQ:132, Table:ZMM_HD/ZMM_DT, Field:ZPO_UNIT, M/O:M, Type:UNIT, Size:3, Description:구매오더수량단위 --> <xs:element name="ZPO_UNIT" type="xs:string"/> <!-- SEQ:133, Table:ZMM_HD/ZMM_DT, Field:ZREF_NETPR, M/O:M, Type:CURR, Size:17,2, Description:참조단가, ZPO_CURR --> - <xs:element name="ZREF_NETPR" type="xs:decimal"/> + <xs:element name="ZREF_NETPR" type="xs:string"/> <!-- SEQ:134, Table:ZMM_HD/ZMM_DT, Field:ZNETPR, M/O:M, Type:CURR, Size:17,2, Description:발주단가, ZPO_CURR --> - <xs:element name="ZNETPR" type="xs:decimal"/> + <xs:element name="ZNETPR" type="xs:string"/> <!-- SEQ:135, Table:ZMM_HD/ZMM_DT, Field:BPRME, M/O:M, Type:UNIT, Size:3, Description:구매단가단위 --> <xs:element name="BPRME" type="xs:string"/> <!-- SEQ:136, Table:ZMM_HD/ZMM_DT, Field:ZDISPLN, M/O:M, Type:CHAR, Size:1, Description:설계기능 --> <xs:element name="ZDISPLN" type="xs:string"/> <!-- SEQ:137, Table:ZMM_HD/ZMM_DT, Field:ZORCT_CNRT_KRW, M/O:M, Type:CURR, Size:17,2, Description:외주비계약KRW --> - <xs:element name="ZORCT_CNRT_KRW" type="xs:decimal"/> + <xs:element name="ZORCT_CNRT_KRW" type="xs:string"/> <!-- SEQ:138, Table:ZMM_HD/ZMM_DT, Field:ZORCT_CNRT_USD, M/O:M, Type:CURR, Size:17,2, Description:외주비계약USD --> - <xs:element name="ZORCT_CNRT_USD" type="xs:decimal"/> + <xs:element name="ZORCT_CNRT_USD" type="xs:string"/> <!-- SEQ:139, Table:ZMM_HD/ZMM_DT, Field:ZETC_CNRT_KRW, M/O:M, Type:CURR, Size:17,2, Description:기타계약KRW --> - <xs:element name="ZETC_CNRT_KRW" type="xs:decimal"/> + <xs:element name="ZETC_CNRT_KRW" type="xs:string"/> <!-- SEQ:140, Table:ZMM_HD/ZMM_DT, Field:ZETC_CNRT_USD, M/O:M, Type:CURR, Size:17,2, Description:기타계약USD --> - <xs:element name="ZETC_CNRT_USD" type="xs:decimal"/> + <xs:element name="ZETC_CNRT_USD" type="xs:string"/> <!-- SEQ:141, Table:ZMM_HD/ZMM_DT, Field:ZEXTRA_AMT, M/O:M, Type:CURR, Size:17,2, Description:EXTRA금액, ZPO_CURR --> - <xs:element name="ZEXTRA_AMT" type="xs:decimal"/> + <xs:element name="ZEXTRA_AMT" type="xs:string"/> <!-- SEQ:142, Table:ZMM_HD/ZMM_DT, Field:ZCRDT_AMT, M/O:M, Type:CURR, Size:17,2, Description:CREDIT금액, ZPO_CURR --> - <xs:element name="ZCRDT_AMT" type="xs:decimal"/> + <xs:element name="ZCRDT_AMT" type="xs:string"/> <!-- SEQ:143, Table:ZMM_HD/ZMM_DT, Field:ZART, M/O:M, Type:CHAR, Size:2, Description:검사코드 --> <xs:element name="ZART" type="xs:string"/> <!-- SEQ:144, Table:ZMM_HD/ZMM_DT, Field:ART, M/O:M, Type:CHAR, Size:8, Description:검사유형(QMAT) --> <xs:element name="ART" type="xs:string"/> <!-- SEQ:145, Table:ZMM_HD/ZMM_DT, Field:ZPDT_BSE_UPR, M/O:M, Type:CURR, Size:17,2, Description:BASE금액, ZPO_CURR --> - <xs:element name="ZPDT_BSE_UPR" type="xs:decimal"/> + <xs:element name="ZPDT_BSE_UPR" type="xs:string"/> <!-- SEQ:146, Table:ZMM_HD/ZMM_DT, Field:ZPDT_EXTRA_UPR, M/O:M, Type:CURR, Size:17,2, Description:EXTRA금액, ZPO_CURR --> - <xs:element name="ZPDT_EXTRA_UPR" type="xs:decimal"/> + <xs:element name="ZPDT_EXTRA_UPR" type="xs:string"/> <!-- SEQ:147, Table:ZMM_HD/ZMM_DT, Field:ZPDT_EXDS_AMT, M/O:M, Type:CURR, Size:17,2, Description:할인/할증금액, ZPO_CURR --> - <xs:element name="ZPDT_EXDS_AMT" type="xs:decimal"/> + <xs:element name="ZPDT_EXDS_AMT" type="xs:string"/> <!-- SEQ:148, Table:ZMM_HD/ZMM_DT, Field:ZTRNS_UPR, M/O:M, Type:CURR, Size:17,2, Description:운송단가, ZPO_CURR --> - <xs:element name="ZTRNS_UPR" type="xs:decimal"/> + <xs:element name="ZTRNS_UPR" type="xs:string"/> <!-- SEQ:149, Table:ZMM_HD/ZMM_DT, Field:ZFST_DST_CD, M/O:M, Type:CHAR, Size:4, Description:발주초기착지코드 --> <xs:element name="ZFST_DST_CD" type="xs:string"/> <!-- SEQ:150, Table:ZMM_HD/ZMM_DT, Field:ZCHG_CHK, M/O:M, Type:CHAR, Size:1, Description:물량수정승인여부 --> @@ -361,32 +359,36 @@ <xs:element name="ZWH_CNTLR" type="xs:string"/> <!-- SEQ:156, Table:ZMM_HD/ZMM_DT, Field:LFDAT, M/O:M, Type:DATS, Size:8, Description:PR Delivery Date --> <xs:element name="LFDAT" type="xs:string"/> - <!-- SEQ:57, Table:ZMM_HD, Field:ETC_2, M/O:M, Type:CHAR, Size:100, Description:확장2 --> + <!-- SEQ:157, Table:ZMM_HD/ZMM_DT, Field:ETC_2, M/O:M, Type:CHAR, Size:100, Description:확장2 --> <xs:element name="ETC_2" type="xs:string"/> - <!-- SEQ:58, Table:ZMM_HD, Field:ETC_3, M/O:M, Type:CHAR, Size:100, Description:확장3 --> + <!-- SEQ:158, Table:ZMM_HD/ZMM_DT, Field:ETC_3, M/O:M, Type:CHAR, Size:100, Description:확장3 --> <xs:element name="ETC_3" type="xs:string"/> - <!-- SEQ:59, Table:ZMM_HD, Field:ETC_4, M/O:M, Type:CHAR, Size:100, Description:확장4 --> + <!-- SEQ:159, Table:ZMM_HD/ZMM_DT, Field:ETC_4, M/O:M, Type:CHAR, Size:100, Description:확장4 --> <xs:element name="ETC_4" type="xs:string"/> - <!-- SEQ:60, Table:ZMM_HD, Field:ETC_5, M/O:M, Type:CHAR, Size:100, Description:확장5 --> + <!-- SEQ:160, Table:ZMM_HD/ZMM_DT, Field:ETC_5, M/O:M, Type:CHAR, Size:100, Description:확장5 --> <xs:element name="ETC_5" type="xs:string"/> - <!-- SEQ:61, Table:ZMM_HD, Field:ETC_6, M/O:M, Type:CHAR, Size:100, Description:확장6 --> + <!-- SEQ:161, Table:ZMM_HD/ZMM_DT, Field:ETC_6, M/O:M, Type:CHAR, Size:100, Description:확장6 --> <xs:element name="ETC_6" type="xs:string"/> - <!-- SEQ:62, Table:ZMM_HD, Field:ETC_7, M/O:M, Type:CHAR, Size:100, Description:확장7 --> + <!-- SEQ:162, Table:ZMM_HD/ZMM_DT, Field:ETC_7, M/O:M, Type:CHAR, Size:100, Description:확장7 --> <xs:element name="ETC_7" type="xs:string"/> - <!-- SEQ:63, Table:ZMM_HD, Field:ETC_8, M/O:M, Type:CHAR, Size:100, Description:확장8 --> + <!-- SEQ:163, Table:ZMM_HD/ZMM_DT, Field:ETC_8, M/O:M, Type:CHAR, Size:100, Description:확장8 --> <xs:element name="ETC_8" type="xs:string"/> - <!-- SEQ:64, Table:ZMM_HD, Field:ETC_9, M/O:M, Type:CHAR, Size:100, Description:확장9 --> + <!-- SEQ:164, Table:ZMM_HD/ZMM_DT, Field:ETC_9, M/O:M, Type:CHAR, Size:100, Description:확장9 --> <xs:element name="ETC_9" type="xs:string"/> - <!-- SEQ:65, Table:ZMM_HD, Field:ETC_10, M/O:M, Type:CHAR, Size:100, Description:확장10 --> + <!-- SEQ:165, Table:ZMM_HD/ZMM_DT, Field:ETC_10, M/O:M, Type:CHAR, Size:100, Description:확장10 --> <xs:element name="ETC_10" type="xs:string"/> + + <!-- PO Detail(ZMM_DT) 의 계정관련 레코드 집합 (ZMM_DT의 하위 테이블) --> + <xs:element name="ZMM_KN" type="tns:ZMM_KN" maxOccurs="unbounded" minOccurs="0"/> + </xs:sequence> </xs:complexType> <!-- 1.5) ZMM_HD/ZMM_DT/ZMM_KN 테이블 구조 (SEQ 166~186) --> - <xs:complexType name="ZMM_HD_ZMM_DT_ZMM_KN"> + <xs:complexType name="ZMM_KN"> <xs:sequence> <!-- SEQ:166, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:ZEKKN, M/O:M, Type:NUMC, Size:2, Description:계정지정순번 --> - <xs:element name="ZEKKN" type="xs:integer"/> + <xs:element name="ZEKKN" type="xs:string"/> <!-- SEQ:167, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:SAKTO, M/O:M, Type:CHAR, Size:10, Description:G/L계정번호 --> <xs:element name="SAKTO" type="xs:string"/> <!-- SEQ:168, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:GSBER, M/O:M, Type:CHAR, Size:4, Description:사업영역코드 --> @@ -396,7 +398,7 @@ <!-- SEQ:170, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:VBELN, M/O:M, Type:CHAR, Size:10, Description:판매오더번호 --> <xs:element name="VBELN" type="xs:string"/> <!-- SEQ:171, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:VBELP, M/O:M, Type:NUMC, Size:6, Description:판매오더품목번호 --> - <xs:element name="VBELP" type="xs:integer"/> + <xs:element name="VBELP" type="xs:string"/> <!-- SEQ:172, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:ANLN1, M/O:M, Type:CHAR, Size:12, Description:주요자산번호 --> <xs:element name="ANLN1" type="xs:string"/> <!-- SEQ:173, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:ANLN2, M/O:M, Type:CHAR, Size:4, Description:자산하위번호 --> @@ -414,9 +416,9 @@ <!-- SEQ:179, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:NPLNR, M/O:M, Type:CHAR, Size:12, Description:네트워크오더번호 --> <xs:element name="NPLNR" type="xs:string"/> <!-- SEQ:180, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:AUFPL, M/O:M, Type:NUMC, Size:10, Description:오더라우팅번호 --> - <xs:element name="AUFPL" type="xs:integer"/> + <xs:element name="AUFPL" type="xs:string"/> <!-- SEQ:181, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:APLZL, M/O:M, Type:NUMC, Size:8, Description:오더내부카운터 --> - <xs:element name="APLZL" type="xs:integer"/> + <xs:element name="APLZL" type="xs:string"/> <!-- SEQ:182, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:FIPOS, M/O:M, Type:CHAR, Size:14, Description:약정항목 --> <xs:element name="FIPOS" type="xs:string"/> <!-- SEQ:183, Table:ZMM_HD/ZMM_DT/ZMM_KN, Field:FISTL, M/O:M, Type:CHAR, Size:16, Description:자금관리센터 --> @@ -431,41 +433,46 @@ </xs:complexType> <!-- 1.6) ZMM_HD/ZMM_NOTE 테이블 구조 (SEQ 187~188) --> - <xs:complexType name="ZMM_HD_ZMM_NOTE"> + <xs:complexType name="ZMM_NOTE"> <xs:sequence> <!-- SEQ:187, Table:ZMM_HD/ZMM_NOTE, Field:ZNOTE_SER, M/O:M, Type:NUMC, Size:4, Description:발주 Note 순번 --> - <xs:element name="ZNOTE_SER" type="xs:integer"/> + <xs:element name="ZNOTE_SER" type="xs:string"/> <!-- SEQ:188, Table:ZMM_HD/ZMM_NOTE, Field:ZNOTE_TXT, M/O:M, Type:CHAR, Size:4000, Description:발주 Note Text --> <xs:element name="ZNOTE_TXT" type="xs:string"/> </xs:sequence> </xs:complexType> <!-- 1.7) ZMM_HD/ZMM_NOTE2 테이블 구조 (SEQ 189~190) --> - <xs:complexType name="ZMM_HD_ZMM_NOTE2"> + <xs:complexType name="ZMM_NOTE2"> <xs:sequence> <!-- SEQ:189, Table:ZMM_HD/ZMM_NOTE2, Field:ZDLV_PRICE_SER, M/O:, Type:NUMC, Size:4, Description:연동제 Note 순번 --> - <xs:element name="ZDLV_PRICE_SER" type="xs:integer" minOccurs="0"/> + <xs:element name="ZDLV_PRICE_SER" type="xs:string" minOccurs="0"/> <!-- SEQ:190, Table:ZMM_HD/ZMM_NOTE2, Field:ZDLV_PRICE_NOTE, M/O:, Type:CHAR, Size:4000, Description:연동제 Note Text --> <xs:element name="ZDLV_PRICE_NOTE" type="xs:string" minOccurs="0"/> </xs:sequence> </xs:complexType> - <!-- 1.8) 수신 시스템 응답 구조 (SEQ 191~193) --> - <xs:complexType name="IF_ECC_EVCP_PO_INFORMATIONRes"> + <!-- 1.8) ZMM_RT 테이블 구조 (SEQ 191~193) - 응답 복합타입 --> + <xs:complexType name="ZMM_RT"> <xs:sequence> - - <!-- SEQ:191, Table:ZMM_HD, Field:EBELN, M/O:M, Type:CHAR, Size:10, Description:구매오더번호 --> + <!-- SEQ:191, Table:ZMM_RT, Field:EBELN, M/O:M, Type:CHAR, Size:10, Description:구매오더번호 --> <xs:element name="EBELN" type="xs:string"/> - <!-- SEQ:192, Table:ZMM_RT, Field:RT_CODE, M/O:M, Type:CHAR, Size:1, Description:IF상태 --> <xs:element name="RT_CODE" type="xs:string"/> - <!-- SEQ:193, Table:ZMM_RT, Field:RT_TEXT, M/O:M, Type:CHAR, Size:100, Description:IF메세지 --> <xs:element name="RT_TEXT" type="xs:string"/> </xs:sequence> </xs:complexType> - <!-- 1.9) Element 래퍼 --> + <!-- 1.9) 수신 시스템 응답 구조 (SEQ 191~193) --> + <xs:complexType name="IF_ECC_EVCP_PO_INFORMATIONRes"> + <xs:sequence> + <!-- 정의한 복합 응답 타입 사용 --> + <xs:element name="ZMM_RT" type="tns:ZMM_RT" minOccurs="1" maxOccurs="1"/> + </xs:sequence> + </xs:complexType> + + <!-- 1.10) Element 래퍼 --> <xs:element name="IF_ECC_EVCP_PO_INFORMATIONReq" type="tns:IF_ECC_EVCP_PO_INFORMATIONReq"/> <xs:element name="IF_ECC_EVCP_PO_INFORMATIONRes" type="tns:IF_ECC_EVCP_PO_INFORMATIONRes"/> </xsd:schema> diff --git a/public/wsdl/IF_ECC_EVCP_PR_INFORMATION.wsdl b/public/wsdl/IF_ECC_EVCP_PR_INFORMATION.wsdl index 705c10bb..d5ef7bf7 100644 --- a/public/wsdl/IF_ECC_EVCP_PR_INFORMATION.wsdl +++ b/public/wsdl/IF_ECC_EVCP_PR_INFORMATION.wsdl @@ -75,29 +75,29 @@ <!-- SEQ:20, Table:T_BID_ITEM, Field:POSID, M/O:, Type:VARCHAR, Size:24, Description:WBS No --> <xs:element name="POSID" type="xs:string" minOccurs="0"/> <!-- SEQ:21, Table:T_BID_ITEM, Field:MENGE, M/O:, Type:NUMERIC, Size:15,3, Description:Purchase Requisition Quantity --> - <xs:element name="MENGE" type="xs:decimal" minOccurs="0"/> + <xs:element name="MENGE" type="xs:string" minOccurs="0"/> <!-- SEQ:22, Table:T_BID_ITEM, Field:MEINS, M/O:, Type:VARCHAR, Size:3, Description:Purchase Requisition Unit of Measure --> <xs:element name="MEINS" type="xs:string" minOccurs="0"/> <!-- SEQ:23, Table:T_BID_ITEM, Field:BPRME, M/O:, Type:VARCHAR, Size:3, Description:Order Price Unit (Purchasing) --> <xs:element name="BPRME" type="xs:string" minOccurs="0"/> <!-- SEQ:24, Table:T_BID_ITEM, Field:BRGEW, M/O:, Type:NUMERIC, Size:15,3, Description:Gross Weight --> - <xs:element name="BRGEW" type="xs:decimal" minOccurs="0"/> + <xs:element name="BRGEW" type="xs:string" minOccurs="0"/> <!-- SEQ:25, Table:T_BID_ITEM, Field:GEWEI, M/O:, Type:VARCHAR, Size:3, Description:Weight Unit --> <xs:element name="GEWEI" type="xs:string" minOccurs="0"/> <!-- SEQ:26, Table:T_BID_ITEM, Field:LFDAT, M/O:, Type:VARCHAR, Size:8, Description:Delivery Date --> <xs:element name="LFDAT" type="xs:string" minOccurs="0"/> <!-- SEQ:27, Table:T_BID_ITEM, Field:PREIS, M/O:, Type:CURR, Size:15,2, Description:Price in Purchase Requisition --> - <xs:element name="PREIS" type="xs:decimal" minOccurs="0"/> + <xs:element name="PREIS" type="xs:string" minOccurs="0"/> <!-- SEQ:28, Table:T_BID_ITEM, Field:WAERS1, M/O:, Type:VARCHAR, Size:5, Description:PR Currency Key --> <xs:element name="WAERS1" type="xs:string" minOccurs="0"/> <!-- SEQ:29, Table:T_BID_ITEM, Field:PEINH, M/O:, Type:NUMERIC, Size:5,0, Description:Price Unit --> - <xs:element name="PEINH" type="xs:decimal" minOccurs="0"/> + <xs:element name="PEINH" type="xs:string" minOccurs="0"/> <!-- SEQ:30, Table:T_BID_ITEM, Field:KNTTP, M/O:, Type:VARCHAR, Size:1, Description:Account Assignment Category --> <xs:element name="KNTTP" type="xs:string" minOccurs="0"/> <!-- SEQ:31, Table:T_BID_ITEM, Field:AUFNR, M/O:, Type:VARCHAR, Size:12, Description:Order Number --> <xs:element name="AUFNR" type="xs:string" minOccurs="0"/> <!-- SEQ:32, Table:T_BID_ITEM, Field:ZRSLT_AMT, M/O:, Type:CURR, Size:17,2, Description:Reference Price --> - <xs:element name="ZRSLT_AMT" type="xs:decimal" minOccurs="0"/> + <xs:element name="ZRSLT_AMT" type="xs:string" minOccurs="0"/> <!-- SEQ:33, Table:T_BID_ITEM, Field:WAERS2, M/O:, Type:VARCHAR, Size:5, Description:Reference Price Currency Key --> <xs:element name="WAERS2" type="xs:string" minOccurs="0"/> <!-- SEQ:34, Table:T_BID_ITEM, Field:ZCON_NO_PO, M/O:, Type:VARCHAR, Size:15, Description:PR Consolidation Number --> diff --git a/public/wsdl/IF_ECC_EVCP_REJECT_FOR_REVISED_PR.wsdl b/public/wsdl/IF_ECC_EVCP_REJECT_FOR_REVISED_PR.wsdl index 07d60fc8..e3f84f41 100644 --- a/public/wsdl/IF_ECC_EVCP_REJECT_FOR_REVISED_PR.wsdl +++ b/public/wsdl/IF_ECC_EVCP_REJECT_FOR_REVISED_PR.wsdl @@ -17,30 +17,22 @@ <!-- 1.1) 최상위 Request 복합타입 --> <xs:complexType name="IF_ECC_EVCP_REJECT_FOR_REVISED_PRReq"> <xs:sequence> - <!-- 메타데이터 레코드 집합 --> - <xs:element name="METADATA" type="tns:METADATA" maxOccurs="unbounded" minOccurs="0"/> + <!-- SEQ:1, Table:없음(최상위), Field:IV_ERDAT, M/O:M, Type:DATS, Size:8, Description:Reject Date --> + <xs:element name="IV_ERDAT" type="xs:string"/> + <!-- SEQ:2, Table:없음(최상위), Field:IV_ERZET, M/O:M, Type:TIMS, Size:6, Description:Reject Time --> + <xs:element name="IV_ERZET" type="xs:string"/> <!-- T_CHANGE_PR 레코드 집합 --> <xs:element name="T_CHANGE_PR" type="tns:T_CHANGE_PR" maxOccurs="unbounded" minOccurs="0"/> </xs:sequence> </xs:complexType> - <!-- 1.2) METADATA 테이블 구조 --> - <xs:complexType name="METADATA"> - <xs:sequence> - <!-- SEQ:1, Table:메타데이터(저장하지 않음), Field:IV_ERDAT, M/O:M, Type:DATS, Size:8, Description:Reject Date --> - <xs:element name="IV_ERDAT" type="xs:string"/> - <!-- SEQ:2, Table:메타데이터(저장하지 않음), Field:IV_ERZET, M/O:M, Type:TIMS, Size:6, Description:Reject Time --> - <xs:element name="IV_ERZET" type="xs:string"/> - </xs:sequence> - </xs:complexType> - <!-- 1.3) T_CHANGE_PR 테이블 구조 --> <xs:complexType name="T_CHANGE_PR"> <xs:sequence> <!-- SEQ:3, Table:T_CHANGE_PR, Field:BANFN, M/O:M, Type:CHAR, Size:10, Description:Purchase Requisition Number --> <xs:element name="BANFN" type="xs:string"/> <!-- SEQ:4, Table:T_CHANGE_PR, Field:BANPO, M/O:M, Type:NUMC, Size:5, Description:Item Number of Purchase Requisition --> - <xs:element name="BANPO" type="xs:integer"/> + <xs:element name="BANPO" type="xs:string"/> <!-- SEQ:5, Table:T_CHANGE_PR, Field:ZCHG_NO, M/O:M, Type:CHAR, Size:10, Description:Change Number --> <xs:element name="ZCHG_NO" type="xs:string"/> <!-- SEQ:6, Table:T_CHANGE_PR, Field:ZACC_IND, M/O:, Type:CHAR, Size:1, Description:P/R Accept Indicator --> @@ -48,11 +40,11 @@ <!-- SEQ:7, Table:T_CHANGE_PR, Field:PCR_REQ, M/O:, Type:CHAR, Size:10, Description:PCR Request No. --> <xs:element name="PCR_REQ" type="xs:string" minOccurs="0"/> <!-- SEQ:8, Table:T_CHANGE_PR, Field:PCR_REQ_SEQ, M/O:, Type:NUMC, Size:5, Description:PCR Request Sequence No. --> - <xs:element name="PCR_REQ_SEQ" type="xs:integer" minOccurs="0"/> + <xs:element name="PCR_REQ_SEQ" type="xs:string" minOccurs="0"/> </xs:sequence> </xs:complexType> - <!-- 1.4) 수신측 응답 구조 --> + <!-- 1.4) 수신측 응답 구조 (최상위, 하위 테이블 구조 없음) --> <xs:complexType name="IF_ECC_EVCP_REJECT_FOR_REVISED_PRRes"> <xs:sequence> <!-- SEQ:9, Table:수신측 응답, Field:EV_TYPE, M/O:, Type:CHAR, Size:1, Description:Message Type --> diff --git a/public/wsdl/IF_EVCP_ECC_PCR_CONFIRM.wsdl b/public/wsdl/IF_EVCP_ECC_PCR_CONFIRM.wsdl index e69de29b..1dc9a9b9 100644 --- a/public/wsdl/IF_EVCP_ECC_PCR_CONFIRM.wsdl +++ b/public/wsdl/IF_EVCP_ECC_PCR_CONFIRM.wsdl @@ -0,0 +1 @@ +<!-- 송신 WSDL 이므로, SAP XI에서 WSDL 생성해서 받을 예정 -->
\ No newline at end of file diff --git a/public/wsdl/품질/IF_ECC_EVCP_PR_INFORMATION.wsdl b/public/wsdl/품질/IF_ECC_EVCP_PR_INFORMATION.wsdl index 09828dda..fb23f7ce 100644 --- a/public/wsdl/품질/IF_ECC_EVCP_PR_INFORMATION.wsdl +++ b/public/wsdl/품질/IF_ECC_EVCP_PR_INFORMATION.wsdl @@ -55,18 +55,18 @@ <xs:element name="MATKL" type="xs:string" minOccurs="0"/> <xs:element name="PSPID" type="xs:string" minOccurs="0"/> <xs:element name="POSID" type="xs:string" minOccurs="0"/> - <xs:element name="MENGE" type="xs:decimal" minOccurs="0"/> + <xs:element name="MENGE" type="xs:string" minOccurs="0"/> <xs:element name="MEINS" type="xs:string" minOccurs="0"/> <xs:element name="BPRME" type="xs:string" minOccurs="0"/> - <xs:element name="BRGEW" type="xs:decimal" minOccurs="0"/> + <xs:element name="BRGEW" type="xs:string" minOccurs="0"/> <xs:element name="GEWEI" type="xs:string" minOccurs="0"/> <xs:element name="LFDAT" type="xs:string" minOccurs="0"/> - <xs:element name="PREIS" type="xs:decimal" minOccurs="0"/> + <xs:element name="PREIS" type="xs:string" minOccurs="0"/> <xs:element name="WAERS1" type="xs:string" minOccurs="0"/> - <xs:element name="PEINH" type="xs:decimal" minOccurs="0"/> + <xs:element name="PEINH" type="xs:string" minOccurs="0"/> <xs:element name="KNTTP" type="xs:string" minOccurs="0"/> <xs:element name="AUFNR" type="xs:string" minOccurs="0"/> - <xs:element name="ZRSLT_AMT" type="xs:decimal" minOccurs="0"/> + <xs:element name="ZRSLT_AMT" type="xs:string" minOccurs="0"/> <xs:element name="WAERS2" type="xs:string" minOccurs="0"/> <xs:element name="ZCON_NO_PO" type="xs:string" minOccurs="0"/> <xs:element name="EBELP" type="xs:string" minOccurs="0"/> |
