diff options
Diffstat (limited to 'lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx')
| -rw-r--r-- | lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx | 540 |
1 files changed, 540 insertions, 0 deletions
diff --git a/lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx new file mode 100644 index 00000000..7d0180df --- /dev/null +++ b/lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx @@ -0,0 +1,540 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Loader, + ArrowUpDown, + ArrowUp, + ArrowDown, + GripVertical, + RotateCcw, + Info +} from "lucide-react" +import { toast } from "sonner" +import { cn } from "@/lib/utils" + +import { reorderGtcClausesSchema, type ReorderGtcClausesSchema } from "@/lib/gtc-contract/gtc-clauses/validations" +import { reorderGtcClauses, getGtcClausesTree } from "@/lib/gtc-contract/gtc-clauses/service" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" + +interface ReorderGtcClausesDialogProps + extends React.ComponentPropsWithRef<typeof Dialog> { + documentId: number + onSuccess?: () => void +} + +interface ClauseWithOrder extends GtcClauseTreeView { + newSortOrder: number + hasChanges: boolean + children?: ClauseWithOrder[] +} + +export function ReorderGtcClausesDialog({ + documentId, + onSuccess, + ...props +}: ReorderGtcClausesDialogProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [clauses, setClauses] = React.useState<ClauseWithOrder[]>([]) + const [originalClauses, setOriginalClauses] = React.useState<ClauseWithOrder[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [draggedItem, setDraggedItem] = React.useState<ClauseWithOrder | null>(null) + const { data: session } = useSession() + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + const form = useForm<ReorderGtcClausesSchema>({ + resolver: zodResolver(reorderGtcClausesSchema), + defaultValues: { + clauses: [], + editReason: "", + }, + }) + + // 조항 데이터 로드 + React.useEffect(() => { + if (props.open && documentId) { + loadClauses() + } + }, [props.open, documentId]) + + const loadClauses = async () => { + setIsLoading(true) + try { + const tree = await getGtcClausesTree(documentId) + const flatClauses = flattenTreeWithOrder(tree) + setClauses(flatClauses) + setOriginalClauses(JSON.parse(JSON.stringify(flatClauses))) // 깊은 복사 + } catch (error) { + console.error("Error loading clauses:", error) + toast.error("조항 목록을 불러오는 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + // 트리를 평면 배열로 변환하면서 순서 정보 추가 + const flattenTreeWithOrder = (tree: any[]): ClauseWithOrder[] => { + const result: ClauseWithOrder[] = [] + + function traverse(nodes: any[], parentId: number | null = null) { + nodes.forEach((node, index) => { + const clauseWithOrder: ClauseWithOrder = { + ...node, + newSortOrder: parseFloat(node.sortOrder), + hasChanges: false, + } + + result.push(clauseWithOrder) + + if (node.children && node.children.length > 0) { + traverse(node.children, node.id) + } + }) + } + + traverse(tree) + return result + } + + // 조항 순서 변경 + const moveClause = (clauseId: number, direction: 'up' | 'down') => { + setClauses(prev => { + const newClauses = [...prev] + const clauseIndex = newClauses.findIndex(c => c.id === clauseId) + + if (clauseIndex === -1) return prev + + const clause = newClauses[clauseIndex] + + // 같은 부모를 가진 형제 조항들 찾기 + const siblings = newClauses.filter(c => c.parentId === clause.parentId) + const siblingIndex = siblings.findIndex(c => c.id === clauseId) + + if (direction === 'up' && siblingIndex > 0) { + // 위로 이동 + const targetSibling = siblings[siblingIndex - 1] + const tempOrder = clause.newSortOrder + clause.newSortOrder = targetSibling.newSortOrder + targetSibling.newSortOrder = tempOrder + + clause.hasChanges = true + targetSibling.hasChanges = true + } else if (direction === 'down' && siblingIndex < siblings.length - 1) { + // 아래로 이동 + const targetSibling = siblings[siblingIndex + 1] + const tempOrder = clause.newSortOrder + clause.newSortOrder = targetSibling.newSortOrder + targetSibling.newSortOrder = tempOrder + + clause.hasChanges = true + targetSibling.hasChanges = true + } + + // sortOrder로 정렬 + return newClauses.sort((a, b) => { + if (a.parentId !== b.parentId) { + // 부모가 다르면 부모 기준으로 정렬 + return (a.parentId || 0) - (b.parentId || 0) + } + return a.newSortOrder - b.newSortOrder + }) + }) + } + + // 변경사항 초기화 + const resetChanges = () => { + setClauses(JSON.parse(JSON.stringify(originalClauses))) + toast.success("변경사항이 초기화되었습니다.") + } + + // 드래그 앤 드롭 핸들러 + const handleDragStart = (e: React.DragEvent, clause: ClauseWithOrder) => { + setDraggedItem(clause) + e.dataTransfer.effectAllowed = 'move' + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + } + + const handleDrop = (e: React.DragEvent, targetClause: ClauseWithOrder) => { + e.preventDefault() + + if (!draggedItem || draggedItem.id === targetClause.id) { + setDraggedItem(null) + return + } + + // 같은 부모를 가진 경우에만 순서 변경 허용 + if (draggedItem.parentId === targetClause.parentId) { + setClauses(prev => { + const newClauses = [...prev] + const draggedIndex = newClauses.findIndex(c => c.id === draggedItem.id) + const targetIndex = newClauses.findIndex(c => c.id === targetClause.id) + + if (draggedIndex !== -1 && targetIndex !== -1) { + const tempOrder = newClauses[draggedIndex].newSortOrder + newClauses[draggedIndex].newSortOrder = newClauses[targetIndex].newSortOrder + newClauses[targetIndex].newSortOrder = tempOrder + + newClauses[draggedIndex].hasChanges = true + newClauses[targetIndex].hasChanges = true + } + + return newClauses.sort((a, b) => { + if (a.parentId !== b.parentId) { + return (a.parentId || 0) - (b.parentId || 0) + } + return a.newSortOrder - b.newSortOrder + }) + }) + } + + setDraggedItem(null) + } + + async function onSubmit(data: ReorderGtcClausesSchema) { + startUpdateTransition(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + // 변경된 조항들만 필터링 + const changedClauses = clauses.filter(c => c.hasChanges).map(c => ({ + id: c.id, + sortOrder: c.newSortOrder, + parentId: c.parentId, + depth: c.depth, + fullPath: c.fullPath, + })) + + if (changedClauses.length === 0) { + toast.error("변경된 조항이 없습니다.") + return + } + + try { + const result = await reorderGtcClauses({ + clauses: changedClauses, + editReason: data.editReason || "조항 순서 변경", + updatedById: currentUserId + }) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success(`${changedClauses.length}개 조항의 순서가 변경되었습니다.`) + onSuccess?.() + } catch (error) { + toast.error("조항 순서 변경 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setClauses([]) + setOriginalClauses([]) + } + props.onOpenChange?.(nextOpen) + } + + const changedCount = clauses.filter(c => c.hasChanges).length + const groupedClauses = groupClausesByParent(clauses) + + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + <DialogContent className="max-w-4xl h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle className="flex items-center gap-2"> + <ArrowUpDown className="h-5 w-5" /> + 조항 순서 변경 + </DialogTitle> + <DialogDescription> + 드래그 앤 드롭 또는 화살표 버튼으로 조항의 순서를 변경하세요. 같은 계층 내에서만 순서 변경이 가능합니다. + </DialogDescription> + </DialogHeader> + + {/* 상태 정보 */} + <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg flex-shrink-0"> + <div className="flex items-center gap-4 text-sm"> + <div className="flex items-center gap-2"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span>총 {clauses.length}개 조항</span> + </div> + {changedCount > 0 && ( + <Badge variant="default"> + {changedCount}개 변경됨 + </Badge> + )} + </div> + + <div className="flex gap-2"> + {changedCount > 0 && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={resetChanges} + > + <RotateCcw className="mr-2 h-4 w-4" /> + 초기화 + </Button> + )} + </div> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + {/* 조항 목록 */} + <ScrollArea className="flex-1 border rounded-lg"> + {isLoading ? ( + <div className="flex items-center justify-center h-32"> + <Loader className="h-6 w-6 animate-spin" /> + <span className="ml-2">조항을 불러오는 중...</span> + </div> + ) : ( + <div className="p-4 space-y-2"> + {Object.entries(groupedClauses).map(([parentInfo, clauses]) => ( + <ClauseGroup + key={parentInfo} + parentInfo={parentInfo} + clauses={clauses} + onMove={moveClause} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + /> + ))} + </div> + )} + </ScrollArea> + + {/* 편집 사유 */} + <div className="mt-4 flex-shrink-0"> + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>편집 사유 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="순서 변경 사유를 입력하세요..." + {...field} + rows={2} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4"> + <Button + type="button" + variant="outline" + onClick={() => props.onOpenChange?.(false)} + disabled={isUpdatePending} + > + Cancel + </Button> + <Button + type="submit" + disabled={isUpdatePending || changedCount === 0} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + <ArrowUpDown className="mr-2 h-4 w-4" /> + Apply Changes ({changedCount}) + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +} + +// 조항 그룹 컴포넌트 +interface ClauseGroupProps { + parentInfo: string + clauses: ClauseWithOrder[] + onMove: (clauseId: number, direction: 'up' | 'down') => void + onDragStart: (e: React.DragEvent, clause: ClauseWithOrder) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent, clause: ClauseWithOrder) => void +} + +function ClauseGroup({ + parentInfo, + clauses, + onMove, + onDragStart, + onDragOver, + onDrop +}: ClauseGroupProps) { + const isRootLevel = parentInfo === "root" + + return ( + <div className="space-y-1"> + {!isRootLevel && ( + <div className="text-sm font-medium text-muted-foreground px-2 py-1 bg-muted/30 rounded"> + {parentInfo} + </div> + )} + + {clauses.map((clause, index) => ( + <ClauseItem + key={clause.id} + clause={clause} + index={index} + isFirst={index === 0} + isLast={index === clauses.length - 1} + onMove={onMove} + onDragStart={onDragStart} + onDragOver={onDragOver} + onDrop={onDrop} + /> + ))} + </div> + ) +} + +// 개별 조항 컴포넌트 +interface ClauseItemProps { + clause: ClauseWithOrder + index: number + isFirst: boolean + isLast: boolean + onMove: (clauseId: number, direction: 'up' | 'down') => void + onDragStart: (e: React.DragEvent, clause: ClauseWithOrder) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent, clause: ClauseWithOrder) => void +} + +function ClauseItem({ + clause, + isFirst, + isLast, + onMove, + onDragStart, + onDragOver, + onDrop +}: ClauseItemProps) { + return ( + <div + className={cn( + "flex items-center gap-2 p-3 border rounded-lg bg-background", + clause.hasChanges && "border-blue-300 bg-blue-50", + "hover:bg-muted/50 transition-colors" + )} + draggable + onDragStart={(e) => onDragStart(e, clause)} + onDragOver={onDragOver} + onDrop={(e) => onDrop(e, clause)} + > + {/* 드래그 핸들 */} + <GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" /> + + {/* 조항 정보 */} + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 mb-1"> + <Badge variant="outline" className="text-xs"> + {clause.itemNumber} + </Badge> + <span className="font-medium truncate">{clause.subtitle}</span> + {clause.hasChanges && ( + <Badge variant="default" className="text-xs"> + 변경됨 + </Badge> + )} + </div> + {clause.content && ( + <p className="text-xs text-muted-foreground line-clamp-1"> + {clause.content.substring(0, 100)}... + </p> + )} + </div> + + {/* 순서 변경 버튼 */} + <div className="flex gap-1"> + <Button + type="button" + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + disabled={isFirst} + onClick={() => onMove(clause.id, 'up')} + > + <ArrowUp className="h-4 w-4" /> + </Button> + <Button + type="button" + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + disabled={isLast} + onClick={() => onMove(clause.id, 'down')} + > + <ArrowDown className="h-4 w-4" /> + </Button> + </div> + </div> + ) +} + +// 조항을 부모별로 그룹화 +function groupClausesByParent(clauses: ClauseWithOrder[]): Record<string, ClauseWithOrder[]> { + const groups: Record<string, ClauseWithOrder[]> = {} + + clauses.forEach(clause => { + const parentKey = clause.parentId + ? `${clause.parentItemNumber || 'Unknown'} - ${clause.parentSubtitle || 'Unknown'}` + : "root" + + if (!groups[parentKey]) { + groups[parentKey] = [] + } + groups[parentKey].push(clause) + }) + + // 각 그룹 내에서 sortOrder로 정렬 + Object.keys(groups).forEach(key => { + groups[key].sort((a, b) => a.newSortOrder - b.newSortOrder) + }) + + return groups +}
\ No newline at end of file |
