summaryrefslogtreecommitdiff
path: root/lib/basic-contract/gtc-vendor/reorder-gtc-clauses-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/basic-contract/gtc-vendor/reorder-gtc-clauses-dialog.tsx')
-rw-r--r--lib/basic-contract/gtc-vendor/reorder-gtc-clauses-dialog.tsx540
1 files changed, 540 insertions, 0 deletions
diff --git a/lib/basic-contract/gtc-vendor/reorder-gtc-clauses-dialog.tsx b/lib/basic-contract/gtc-vendor/reorder-gtc-clauses-dialog.tsx
new file mode 100644
index 00000000..7d0180df
--- /dev/null
+++ b/lib/basic-contract/gtc-vendor/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