summaryrefslogtreecommitdiff
path: root/lib/gtc-contract/status
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gtc-contract/status')
-rw-r--r--lib/gtc-contract/status/create-gtc-document-dialog.tsx272
-rw-r--r--lib/gtc-contract/status/create-new-revision-dialog.tsx157
-rw-r--r--lib/gtc-contract/status/delete-gtc-documents-dialog.tsx168
-rw-r--r--lib/gtc-contract/status/gtc-contract-table.tsx173
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-columns.tsx291
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx90
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx39
-rw-r--r--lib/gtc-contract/status/update-gtc-document-sheet.tsx148
8 files changed, 1338 insertions, 0 deletions
diff --git a/lib/gtc-contract/status/create-gtc-document-dialog.tsx b/lib/gtc-contract/status/create-gtc-document-dialog.tsx
new file mode 100644
index 00000000..6791adfa
--- /dev/null
+++ b/lib/gtc-contract/status/create-gtc-document-dialog.tsx
@@ -0,0 +1,272 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Check, ChevronsUpDown, Loader, Plus } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+
+import { createGtcDocumentSchema, type CreateGtcDocumentSchema } from "@/lib/gtc-contract/validations"
+import { createGtcDocument, getProjectsForSelect } from "@/lib/gtc-contract/service"
+import { type Project } from "@/db/schema/projects"
+
+export function CreateGtcDocumentDialog() {
+ const [open, setOpen] = React.useState(false)
+ const [projects, setProjects] = React.useState<Project[]>([])
+ const [isCreatePending, startCreateTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ if (open) {
+ getProjectsForSelect().then((res) => {
+ setProjects(res)
+ })
+ }
+ }, [open])
+
+ const form = useForm<CreateGtcDocumentSchema>({
+ resolver: zodResolver(createGtcDocumentSchema),
+ defaultValues: {
+ type: "standard",
+ projectId: null,
+ revision: 0,
+ editReason: "",
+ },
+ })
+
+ const watchedType = form.watch("type")
+
+ async function onSubmit(data: CreateGtcDocumentSchema) {
+ startCreateTransition(async () => {
+ try {
+ const result = await createGtcDocument(data)
+
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+
+ form.reset()
+ setOpen(false)
+ toast.success("GTC 문서가 생성되었습니다.")
+ } catch (error) {
+ toast.error("문서 생성 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ Add GTC Document
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>Create New GTC Document</DialogTitle>
+ <DialogDescription>
+ 새 GTC 문서 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* 구분 (Type) */}
+ <FormField
+ control={form.control}
+ name="type"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구분</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={(value) => {
+ field.onChange(value)
+ // 표준으로 변경시 프로젝트 ID 초기화
+ if (value === "standard") {
+ form.setValue("projectId", null)
+ }
+ }}
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="구분을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="standard">표준</SelectItem>
+ <SelectItem value="project">프로젝트</SelectItem>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 (프로젝트 타입인 경우만) */}
+ {watchedType === "project" && (
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => {
+ const selectedProject = projects.find(
+ (p) => p.id === field.value
+ )
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ return (
+ <FormItem>
+ <FormLabel>프로젝트</FormLabel>
+ <FormControl>
+ <Popover
+ open={popoverOpen}
+ onOpenChange={setPopoverOpen}
+ modal={true}
+ >
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ >
+ {selectedProject
+ ? `${selectedProject.name} (${selectedProject.code})`
+ : "프로젝트를 선택하세요..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput
+ placeholder="프로젝트 검색..."
+ className="h-9"
+ />
+ <CommandList>
+ <CommandEmpty>프로젝트를 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {projects.map((project) => {
+ const label = `${project.name} (${project.code})`
+ return (
+ <CommandItem
+ key={project.id}
+ value={label}
+ onSelect={() => {
+ field.onChange(project.id)
+ setPopoverOpen(false)
+ }}
+ >
+ {label}
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ selectedProject?.id === project.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+ )}
+
+ {/* 편집 사유 */}
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>편집 사유 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="편집 사유를 입력하세요..."
+ {...field}
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isCreatePending}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isCreatePending}>
+ {isCreatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/status/create-new-revision-dialog.tsx b/lib/gtc-contract/status/create-new-revision-dialog.tsx
new file mode 100644
index 00000000..e18e6352
--- /dev/null
+++ b/lib/gtc-contract/status/create-new-revision-dialog.tsx
@@ -0,0 +1,157 @@
+"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 {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Loader } from "lucide-react"
+import { toast } from "sonner"
+
+import { createNewRevisionSchema, type CreateNewRevisionSchema } from "@/lib/gtc-contract/validations"
+import { createNewRevision } from "@/lib/gtc-contract/service"
+import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
+
+interface CreateNewRevisionDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ originalDocument: GtcDocumentWithRelations | null
+}
+
+export function CreateNewRevisionDialog({
+ open,
+ onOpenChange,
+ originalDocument,
+}: CreateNewRevisionDialogProps) {
+ const [isCreatePending, startCreateTransition] = React.useTransition()
+
+ const form = useForm<CreateNewRevisionSchema>({
+ resolver: zodResolver(createNewRevisionSchema),
+ defaultValues: {
+ editReason: "",
+ },
+ })
+
+ // 다이얼로그가 열릴 때마다 폼 초기화
+ React.useEffect(() => {
+ if (open && originalDocument) {
+ form.reset({
+ editReason: "",
+ })
+ }
+ }, [open, originalDocument, form])
+
+ async function onSubmit(data: CreateNewRevisionSchema) {
+ if (!originalDocument) {
+ toast.error("원본 문서 정보가 없습니다.")
+ return
+ }
+
+ startCreateTransition(async () => {
+ try {
+ const result = await createNewRevision(originalDocument.id, data)
+
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+
+ form.reset()
+ onOpenChange(false)
+ toast.success(`새 리비전 v${result.revision}이 생성되었습니다.`)
+ } catch (error) {
+ toast.error("리비전 생성 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ onOpenChange(nextOpen)
+ }
+
+ if (!originalDocument) return null
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>Create New Revision</DialogTitle>
+ <DialogDescription>
+ 기존 문서의 새로운 리비전을 생성합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 원본 문서 정보 표시 */}
+ <div className="space-y-2 p-3 bg-muted/50 rounded-lg">
+ <div className="text-sm font-medium">원본 문서 정보</div>
+ <div className="text-xs text-muted-foreground space-y-1">
+ <div>구분: {originalDocument.type === "standard" ? "표준" : "프로젝트"}</div>
+ {originalDocument.project && (
+ <div>프로젝트: {originalDocument.project.name} ({originalDocument.project.code})</div>
+ )}
+ <div>현재 리비전: v{originalDocument.revision}</div>
+ </div>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* 편집 사유 (필수) */}
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>편집 사유 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="새 리비전 생성 사유를 입력하세요..."
+ {...field}
+ rows={3}
+ required
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isCreatePending}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isCreatePending}>
+ {isCreatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Create Revision
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx b/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx
new file mode 100644
index 00000000..5779a2b6
--- /dev/null
+++ b/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx
@@ -0,0 +1,168 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { deleteGtcDocuments } from "@/lib/gtc-contract/service"
+import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
+
+interface DeleteGtcDocumentsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ gtcDocuments: Row<GtcDocumentWithRelations>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteGtcDocumentsDialog({
+ gtcDocuments,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteGtcDocumentsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await deleteGtcDocuments({
+ ids: gtcDocuments.map((doc) => doc.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success(
+ `${gtcDocuments.length}개의 GTC 문서가 삭제되었습니다.`
+ )
+ onSuccess?.()
+ })
+ }
+
+ const title = "Are you absolutely sure?"
+ const description = (
+ <>
+ 이 작업은 되돌릴 수 없습니다. 선택된{" "}
+ <span className="font-medium">{gtcDocuments.length}개</span>의 GTC 문서가 영구적으로 삭제됩니다.
+ {gtcDocuments.length > 0 && (
+ <div className="mt-2 max-h-32 overflow-y-auto">
+ <div className="text-sm text-muted-foreground">삭제될 문서:</div>
+ <ul className="mt-1 text-sm">
+ {gtcDocuments.slice(0, 5).map((doc) => (
+ <li key={doc.id} className="truncate">
+ • {doc.fileName} (v{doc.revision})
+ </li>
+ ))}
+ {gtcDocuments.length > 5 && (
+ <li className="text-muted-foreground">
+ ... 외 {gtcDocuments.length - 5}개
+ </li>
+ )}
+ </ul>
+ </div>
+ )}
+ </>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({gtcDocuments.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <DialogDescription>{description}</DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected documents"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({gtcDocuments.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>{title}</DrawerTitle>
+ <DrawerDescription>{description}</DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected documents"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/status/gtc-contract-table.tsx b/lib/gtc-contract/status/gtc-contract-table.tsx
new file mode 100644
index 00000000..dd04fbc9
--- /dev/null
+++ b/lib/gtc-contract/status/gtc-contract-table.tsx
@@ -0,0 +1,173 @@
+"use client"
+
+import * as React from "react"
+import { gtcDocuments, type GtcDocumentWithRelations } from "@/db/schema/gtc"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+import type {
+ getGtcDocuments,
+ getProjectsForFilter,
+ getUsersForFilter
+} from "@/lib/gtc-contract/service"
+import { getColumns } from "./gtc-documents-table-columns"
+import { GtcDocumentsTableToolbarActions } from "./gtc-documents-table-toolbar-actions"
+import { DeleteGtcDocumentsDialog } from "./delete-gtc-documents-dialog"
+import { GtcDocumentsTableFloatingBar } from "./gtc-documents-table-floating-bar"
+import { UpdateGtcDocumentSheet } from "./update-gtc-document-sheet"
+import { CreateGtcDocumentDialog } from "./create-gtc-document-dialog"
+import { CreateNewRevisionDialog } from "./create-new-revision-dialog"
+
+interface GtcDocumentsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getGtcDocuments>>,
+ Awaited<ReturnType<typeof getProjectsForFilter>>,
+ Awaited<ReturnType<typeof getUsersForFilter>>
+ ]
+ >
+}
+
+export function GtcDocumentsTable({ promises }: GtcDocumentsTableProps) {
+ const [{ data, pageCount }, projects, users] = React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<GtcDocumentWithRelations> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ /**
+ * Filter fields for the data table.
+ */
+ const filterFields: DataTableFilterField<GtcDocumentWithRelations>[] = [
+ {
+ id: "editReason",
+ label: "Edit Reason",
+ placeholder: "Filter by edit reason...",
+ },
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<GtcDocumentWithRelations>[] = [
+ {
+ id: "type",
+ label: "Type",
+ type: "multi-select",
+ options: [
+ { label: "Standard", value: "standard" },
+ { label: "Project", value: "project" },
+ ],
+ },
+ {
+ id: "editReason",
+ label: "Edit Reason",
+ type: "text",
+ },
+ {
+ id: "project.name",
+ label: "Project",
+ type: "multi-select",
+ options: projects.map((project) => ({
+ label: `${project.name} (${project.code})`,
+ value: project.name,
+ })),
+ },
+ {
+ id: "createdBy.name",
+ label: "Created By",
+ type: "multi-select",
+ options: users.map((user) => ({
+ label: user.name,
+ value: user.name,
+ })),
+ },
+ {
+ id: "updatedBy.name",
+ label: "Updated By",
+ type: "multi-select",
+ options: users.map((user) => ({
+ label: user.name,
+ value: user.name,
+ })),
+ },
+ {
+ id: "createdAt",
+ label: "Created At",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated At",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.id}`,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ floatingBar={<GtcDocumentsTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <GtcDocumentsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteGtcDocumentsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ gtcDocuments={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+ <UpdateGtcDocumentSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ gtcDocument={rowAction?.row.original ?? null}
+ />
+
+ <CreateNewRevisionDialog
+ open={rowAction?.type === "createRevision"}
+ onOpenChange={() => setRowAction(null)}
+ originalDocument={rowAction?.row.original ?? null}
+ />
+
+ <CreateGtcDocumentDialog />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/status/gtc-documents-table-columns.tsx b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
new file mode 100644
index 00000000..2d5f08b9
--- /dev/null
+++ b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
@@ -0,0 +1,291 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, Eye } from "lucide-react"
+
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
+import { useRouter } from "next/navigation"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GtcDocumentWithRelations> | null>>
+}
+
+/**
+ * GTC Documents 테이블 컬럼 정의
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<GtcDocumentWithRelations>[] {
+ const router = useRouter()
+
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<GtcDocumentWithRelations> = {
+ 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"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 기본 정보 그룹
+ // ----------------------------------------------------------------
+ const basicInfoColumns: ColumnDef<GtcDocumentWithRelations>[] = [
+ {
+ accessorKey: "type",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />,
+ cell: ({ row }) => {
+ const type = row.getValue("type") as string;
+ return (
+ <Badge variant={type === "standard" ? "default" : "secondary"}>
+ {type === "standard" ? "표준" : "프로젝트"}
+ </Badge>
+ );
+ },
+ size: 100,
+ enableResizing: true,
+ meta: {
+ excelHeader: "구분",
+ },
+ },
+ {
+ accessorKey: "project",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트" />,
+ cell: ({ row }) => {
+ const project = row.original.project;
+ if (!project) {
+ return <span className="text-muted-foreground">-</span>;
+ }
+ return (
+ <div className="flex flex-col min-w-0">
+ <span className="font-medium truncate">{project.name}</span>
+ <span className="text-xs text-muted-foreground">{project.code}</span>
+ </div>
+ );
+ },
+ size: 200,
+ enableResizing: true,
+ meta: {
+ excelHeader: "프로젝트",
+ },
+ },
+ {
+ accessorKey: "revision",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Rev." />,
+ cell: ({ row }) => {
+ const revision = row.getValue("revision") as number;
+ return <span className="font-mono text-sm">v{revision}</span>;
+ },
+ size: 80,
+ enableResizing: true,
+ meta: {
+ excelHeader: "Rev.",
+ },
+ },
+ ];
+
+ // ----------------------------------------------------------------
+ // 3) 등록/수정 정보 그룹
+ // ----------------------------------------------------------------
+ const auditColumns: ColumnDef<GtcDocumentWithRelations>[] = [
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최초등록일" />,
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date;
+ return date ? formatDate(date, "KR") : "-";
+ },
+ size: 120,
+ enableResizing: true,
+ meta: {
+ excelHeader: "최초등록일",
+ },
+ },
+ {
+ accessorKey: "createdBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최초등록자" />,
+ cell: ({ row }) => {
+ const createdBy = row.original.createdBy;
+ return createdBy ? (
+ <span className="text-sm">{createdBy.name}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 120,
+ enableResizing: true,
+ meta: {
+ excelHeader: "최초등록자",
+ },
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
+ cell: ({ row }) => {
+ const date = row.getValue("updatedAt") as Date;
+ return date ? formatDate(date, "KR") : "-";
+ },
+ size: 120,
+ enableResizing: true,
+ meta: {
+ excelHeader: "최종수정일",
+ },
+ },
+ {
+ accessorKey: "updatedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
+ cell: ({ row }) => {
+ const updatedBy = row.original.updatedBy;
+ return updatedBy ? (
+ <span className="text-sm">{updatedBy.name}</span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 120,
+ enableResizing: true,
+ meta: {
+ excelHeader: "최종수정자",
+ },
+ },
+ {
+ accessorKey: "editReason",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종 편집사유" />,
+ cell: ({ row }) => {
+ const reason = row.getValue("editReason") as string;
+ return reason ? (
+ <span className="text-sm" title={reason}>
+ {reason.length > 30 ? `${reason.substring(0, 30)}...` : reason}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 200,
+ enableResizing: true,
+ meta: {
+ excelHeader: "최종 편집사유",
+ },
+ },
+ ];
+
+ // ----------------------------------------------------------------
+ // 4) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<GtcDocumentWithRelations> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const gtcDocument = row.original;
+
+ const handleViewDetails = () => {
+ router.push(`/evcp/gtc-documents/${gtcDocument.id}`);
+ };
+
+ const handleCreateNewRevision = () => {
+ setRowAction({ row, type: "createRevision" });
+ };
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuItem onSelect={handleViewDetails}>
+ <Eye className="mr-2 h-4 w-4" />
+ View Details
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuItem onSelect={handleCreateNewRevision}>
+ Create New Revision
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 5) 중첩 컬럼 그룹 생성
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<GtcDocumentWithRelations>[] = [
+ {
+ id: "기본 정보",
+ header: "기본 정보",
+ columns: basicInfoColumns,
+ },
+ {
+ id: "등록/수정 정보",
+ header: "등록/수정 정보",
+ columns: auditColumns,
+ },
+ ]
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx b/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx
new file mode 100644
index 00000000..a9139ed2
--- /dev/null
+++ b/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx
@@ -0,0 +1,90 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, X } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { Separator } from "@/components/ui/separator"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+
+import { exportTableToCSV } from "@/lib/export"
+import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
+import { DeleteGtcDocumentsDialog } from "./delete-gtc-documents-dialog"
+
+interface GtcDocumentsTableFloatingBarProps {
+ table: Table<GtcDocumentWithRelations>
+}
+
+export function GtcDocumentsTableFloatingBar({
+ table,
+}: GtcDocumentsTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ const [isPending, startTransition] = React.useTransition()
+
+ // Clear selection on Escape key press
+ React.useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ table.toggleAllRowsSelected(false)
+ }
+ }
+
+ document.addEventListener("keydown", handleKeyDown)
+ return () => document.removeEventListener("keydown", handleKeyDown)
+ }, [table])
+
+ return (
+ <div className="fixed inset-x-0 bottom-4 z-50 mx-auto w-fit px-4">
+ <div className="w-full overflow-x-auto">
+ <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-card p-2 shadow-2xl">
+ <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
+ <span className="whitespace-nowrap text-xs">
+ {rows.length} selected
+ </span>
+ <Separator orientation="vertical" className="ml-2 mr-1" />
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5 hover:border"
+ onClick={() => table.toggleAllRowsSelected(false)}
+ >
+ <X className="size-3.5 shrink-0" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Clear selection</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <Separator orientation="vertical" className="hidden h-5 sm:block" />
+ <div className="flex items-center gap-1.5">
+ <Button
+ variant="secondary"
+ size="sm"
+ onClick={() =>
+ exportTableToCSV(table, {
+ filename: "gtc-documents",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ disabled={isPending}
+ >
+ <Download className="mr-2 size-4" aria-hidden="true" />
+ Export
+ </Button>
+ <DeleteGtcDocumentsDialog
+ gtcDocuments={rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx b/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx
new file mode 100644
index 00000000..cb52b2ed
--- /dev/null
+++ b/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx
@@ -0,0 +1,39 @@
+"use client"
+
+import { type Table } from "@tanstack/react-table"
+import { Download } from "lucide-react"
+
+import { exportTableToCSV } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+
+import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
+import { CreateGtcDocumentDialog } from "./create-gtc-document-dialog"
+
+interface GtcDocumentsTableToolbarActionsProps {
+ table: Table<GtcDocumentWithRelations>
+}
+
+export function GtcDocumentsTableToolbarActions({
+ table,
+}: GtcDocumentsTableToolbarActionsProps) {
+ return (
+ <div className="flex items-center gap-2">
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToCSV(table, {
+ filename: "gtc-documents",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ >
+ <Download className="mr-2 size-4" aria-hidden="true" />
+ Export ({table.getFilteredSelectedRowModel().rows.length})
+ </Button>
+ ) : null}
+ <CreateGtcDocumentDialog />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/status/update-gtc-document-sheet.tsx b/lib/gtc-contract/status/update-gtc-document-sheet.tsx
new file mode 100644
index 00000000..9d133ecc
--- /dev/null
+++ b/lib/gtc-contract/status/update-gtc-document-sheet.tsx
@@ -0,0 +1,148 @@
+"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 {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+
+import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
+import { updateGtcDocumentSchema, type UpdateGtcDocumentSchema } from "@/lib/gtc-contract/validations"
+import { updateGtcDocument } from "@/lib/gtc-contract/service"
+
+export interface UpdateGtcDocumentSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ gtcDocument: GtcDocumentWithRelations | null
+}
+
+export function UpdateGtcDocumentSheet({ gtcDocument, ...props }: UpdateGtcDocumentSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateGtcDocumentSchema>({
+ resolver: zodResolver(updateGtcDocumentSchema),
+ defaultValues: {
+ editReason: "",
+ isActive: gtcDocument?.isActive ?? true,
+ },
+ })
+
+ // gtcDocument prop 바뀔 때마다 form.reset
+ React.useEffect(() => {
+ if (gtcDocument) {
+ form.reset({
+ editReason: "",
+ isActive: gtcDocument.isActive,
+ })
+ }
+ }, [gtcDocument, form])
+
+ async function onSubmit(input: UpdateGtcDocumentSchema) {
+ startUpdateTransition(async () => {
+ if (!gtcDocument) return
+
+ try {
+ const result = await updateGtcDocument(gtcDocument.id, input)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("GTC 문서가 업데이트되었습니다!")
+ } catch (error) {
+ toast.error("문서 업데이트 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update GTC Document</SheetTitle>
+ <SheetDescription>
+ GTC 문서 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* 문서 정보 표시 */}
+ <div className="space-y-2 p-3 bg-muted/50 rounded-lg">
+ <div className="text-sm font-medium">현재 문서 정보</div>
+ <div className="text-xs text-muted-foreground space-y-1">
+ <div>구분: {gtcDocument?.type === "standard" ? "표준" : "프로젝트"}</div>
+ {gtcDocument?.project && (
+ <div>프로젝트: {gtcDocument.project.name} ({gtcDocument.project.code})</div>
+ )}
+ <div>리비전: v{gtcDocument?.revision}</div>
+ </div>
+ </div>
+
+ {/* 편집 사유 */}
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>편집 사유 (권장)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="편집 사유를 입력하세요..."
+ {...field}
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+
+ <Button type="submit" disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file