diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
| commit | e0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch) | |
| tree | 68543a65d88f5afb3a0202925804103daa91bc6f /lib/tasks/table | |
3/25 까지의 대표님 작업사항
Diffstat (limited to 'lib/tasks/table')
| -rw-r--r-- | lib/tasks/table/add-tasks-dialog.tsx | 227 | ||||
| -rw-r--r-- | lib/tasks/table/delete-tasks-dialog.tsx | 149 | ||||
| -rw-r--r-- | lib/tasks/table/feature-flags-provider.tsx | 108 | ||||
| -rw-r--r-- | lib/tasks/table/feature-flags.tsx | 96 | ||||
| -rw-r--r-- | lib/tasks/table/tasks-table-columns.tsx | 262 | ||||
| -rw-r--r-- | lib/tasks/table/tasks-table-floating-bar.tsx | 354 | ||||
| -rw-r--r-- | lib/tasks/table/tasks-table-toolbar-actions.tsx | 117 | ||||
| -rw-r--r-- | lib/tasks/table/tasks-table.tsx | 197 | ||||
| -rw-r--r-- | lib/tasks/table/update-task-sheet.tsx | 230 |
9 files changed, 1740 insertions, 0 deletions
diff --git a/lib/tasks/table/add-tasks-dialog.tsx b/lib/tasks/table/add-tasks-dialog.tsx new file mode 100644 index 00000000..18a9a4b2 --- /dev/null +++ b/lib/tasks/table/add-tasks-dialog.tsx @@ -0,0 +1,227 @@ +"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 { Input } from "@/components/ui/input" +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +// shadcn/ui Select +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { tasks } from "@/db/schema/tasks" // enumValues 가져올 DB 스키마 +import { createTaskSchema, type CreateTaskSchema } from "@/lib/tasks/validations" +import { createTask } from "@/lib/tasks/service" // 서버 액션 혹은 API + +export function AddTaskDialog() { + const [open, setOpen] = React.useState(false) + + // react-hook-form 세팅 + const form = useForm<CreateTaskSchema>({ + resolver: zodResolver(createTaskSchema), + defaultValues: { + title: "", + label: tasks.label.enumValues[0] ?? "", // enumValues 중 첫 번째를 기본값으로 + status: tasks.status.enumValues[0] ?? "", + priority: tasks.priority.enumValues[0] ?? "", + }, + }) + + async function onSubmit(data: CreateTaskSchema) { + const result = await createTask(data) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setOpen(false) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Task + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New Task</DialogTitle> + <DialogDescription> + 새 Task 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* Title 필드 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>Title</FormLabel> + <FormControl> + <Input + placeholder="e.g. Fix the layout bug" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Label (Select) */} + <FormField + control={form.control} + name="label" + render={({ field }) => ( + <FormItem> + <FormLabel>Label</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + value={field.value} + > + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a label" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {tasks.label.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Status (Select) */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + value={field.value} + > + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {tasks.status.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Priority (Select) */} + <FormField + control={form.control} + name="priority" + render={({ field }) => ( + <FormItem> + <FormLabel>Priority</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + value={field.value} + > + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a priority" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {tasks.priority.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + > + Cancel + </Button> + <Button type="submit" disabled={form.formState.isSubmitting}> + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tasks/table/delete-tasks-dialog.tsx b/lib/tasks/table/delete-tasks-dialog.tsx new file mode 100644 index 00000000..c82c913e --- /dev/null +++ b/lib/tasks/table/delete-tasks-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +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 { removeTasks } from "@/lib//tasks/service" + +interface DeleteTasksDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + tasks: Row<Task>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteTasksDialog({ + tasks, + showTrigger = true, + onSuccess, + ...props +}: DeleteTasksDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeTasks({ + ids: tasks.map((task) => task.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({tasks.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{tasks.length}</span> + {tasks.length === 1 ? " task" : " tasks"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + 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 ({tasks.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{tasks.length}</span> + {tasks.length === 1 ? " task" : " tasks"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/tasks/table/feature-flags-provider.tsx b/lib/tasks/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tasks/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/tasks/table/feature-flags.tsx b/lib/tasks/table/feature-flags.tsx new file mode 100644 index 00000000..aaae6af2 --- /dev/null +++ b/lib/tasks/table/feature-flags.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface TasksTableContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const TasksTableContext = React.createContext<TasksTableContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useTasksTable() { + const context = React.useContext(TasksTableContext) + if (!context) { + throw new Error("useTasksTable must be used within a TasksTableProvider") + } + return context +} + +export function TasksTableProvider({ children }: React.PropsWithChildren) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "featureFlags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + } + ) + + return ( + <TasksTableContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit" + > + {dataTableConfig.featureFlags.map((flag) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className="whitespace-nowrap px-3 text-xs" + asChild + > + <TooltipTrigger> + <flag.icon + className="mr-2 size-3.5 shrink-0" + aria-hidden="true" + /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </TasksTableContext.Provider> + ) +} diff --git a/lib/tasks/table/tasks-table-columns.tsx b/lib/tasks/table/tasks-table-columns.tsx new file mode 100644 index 00000000..3737c2e5 --- /dev/null +++ b/lib/tasks/table/tasks-table-columns.tsx @@ -0,0 +1,262 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } 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, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" + +import { modifiTask } from "@/lib/tasks/service" +import { getPriorityIcon, getStatusIcon } from "@/lib/tasks/utils" +import { tasks } from "@/db/schema/tasks" +import type { Task } from "@/db/schema/tasks" + +import { tasksColumnsConfig } from "@/config/tasksColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Task> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Task>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<Task> = { + 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) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<Task> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + 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-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.label} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + modifiTask({ + id: row.original.id, + label: value as Task["label"], + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {tasks.label.enumValues.map((label) => ( + <DropdownMenuRadioItem + key={label} + value={label} + className="capitalize" + disabled={isUpdatePending} + > + {label} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<Task>[] } + const groupMap: Record<string, ColumnDef<Task>[]> = {} + + tasksColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<Task> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeader column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // 예: cfg.id === "title" → custom rendering + if (cfg.id === "title") { + const labelVal = row.original.label + const labelExists = tasks.label.enumValues.includes(labelVal ?? "") + return ( + <div className="flex space-x-2"> + {labelExists && <Badge variant="outline">{labelVal}</Badge>} + <span className="max-w-[31.25rem] truncate font-medium"> + {row.getValue("title")} + </span> + </div> + ) + } + + if (cfg.id === "status") { + const statusVal = row.original.status + if (!statusVal) return null + const Icon = getStatusIcon(statusVal) + return ( + <div className="flex w-[6.25rem] items-center"> + <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> + <span className="capitalize">{statusVal}</span> + </div> + ) + } + + if (cfg.id === "priority") { + const priorityVal = row.original.priority + if (!priorityVal) return null + const Icon = getPriorityIcon(priorityVal) + return ( + <div className="flex items-center"> + <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> + <span className="capitalize">{priorityVal}</span> + </div> + ) + } + + if (cfg.id === "archived") { + return ( + <Badge variant="outline"> + {row.original.archived ? "Yes" : "No"} + </Badge> + ) + } + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<Task>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/tasks/table/tasks-table-floating-bar.tsx b/lib/tasks/table/tasks-table-floating-bar.tsx new file mode 100644 index 00000000..6d367f81 --- /dev/null +++ b/lib/tasks/table/tasks-table-floating-bar.tsx @@ -0,0 +1,354 @@ +"use client" + +import * as React from "react" +import { tasks, type Task } from "@/db/schema/tasks" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { removeTasks, modifiTasks } from "@/lib//tasks/service" +import { DeleteTasksDialog } from "./delete-tasks-dialog" +import { ActionConfirmDialog } from "@/components/ui/action-dialog" + +interface TasksTableFloatingBarProps { + table: Table<Task> +} + + +export function TasksTableFloatingBar({ table }: TasksTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "update-priority" | "export" | "delete" + >() + const [popoverOpen, setPopoverOpen] = React.useState(false) + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + // 1) "삭제" Confirm 열기 + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeTasks({ + ids: rows.map((row) => row.original.id), + }) + if (error) { + toast.error(error) + return + } + toast.success("Users deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 2) + function handleSelectStatus(newStatus: Task["status"]) { + setAction("update-status") + + setConfirmProps({ + title: `Update ${rows.length} task${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + description: "This action will override their current status.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiTasks({ + ids: rows.map((row) => row.original.id), + status: newStatus, + }) + if (error) { + toast.error(error) + return + } + toast.success("Tasks updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 3) + function handleSelectPriority(newPriority: Task["priority"]) { + setAction("update-priority") + + setConfirmProps({ + title: `Update ${rows.length} task${rows.length > 1 ? "s" : ""} with priority: ${newPriority}?`, + description: "This action will override their current priority.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiTasks({ + ids: rows.map((row) => row.original.id), + priority: newPriority, + }) + if (error) { + toast.error(error) + return + } + toast.success("Tasks updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <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" /> + <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="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + <Select + onValueChange={(value: Task["status"]) => { + handleSelectStatus(value) + }} + > + <Tooltip> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-status" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <CheckCircle2 + className="size-3.5" + aria-hidden="true" + /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update status</p> + </TooltipContent> + </Tooltip> + <SelectContent align="center"> + <SelectGroup> + {tasks.status.enumValues.map((status) => ( + <SelectItem + key={status} + value={status} + className="capitalize" + > + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <Select + onValueChange={(value: Task["priority"]) => { + handleSelectPriority(value) + }} + > + <Tooltip> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-priority" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <ArrowUp className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update priority</p> + </TooltipContent> + </Tooltip> + <SelectContent align="center"> + <SelectGroup> + {tasks.priority.enumValues.map((priority) => ( + <SelectItem + key={priority} + value={priority} + className="capitalize" + > + {priority} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export tasks</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete tasks</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-priority" || action === "update-status")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-priority" || action === "update-status" + ? "Update" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +} diff --git a/lib/tasks/table/tasks-table-toolbar-actions.tsx b/lib/tasks/table/tasks-table-toolbar-actions.tsx new file mode 100644 index 00000000..8219b7b6 --- /dev/null +++ b/lib/tasks/table/tasks-table-toolbar-actions.tsx @@ -0,0 +1,117 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + +// 삭제, 추가 다이얼로그 +import { DeleteTasksDialog } from "./delete-tasks-dialog" +import { AddTaskDialog } from "./add-tasks-dialog" + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 + +interface TasksTableToolbarActionsProps { + table: Table<Task> +} + +export function TasksTableToolbarActions({ table }: TasksTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (err) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + + } + } + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteTasksDialog + tasks={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 2) 새 Task 추가 다이얼로그 */} + <AddTaskDialog /> + + {/** 3) Import 버튼 (파일 업로드) */} + <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tasks/table/tasks-table.tsx b/lib/tasks/table/tasks-table.tsx new file mode 100644 index 00000000..ab448a7b --- /dev/null +++ b/lib/tasks/table/tasks-table.tsx @@ -0,0 +1,197 @@ +"use client" + +import * as React from "react" +import { tasks, type Task } from "@/db/schema/tasks" +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 { + getTaskPriorityCounts, + getTasks, + getTaskStatusCounts, +} from "@/lib//tasks/service" +import { getPriorityIcon, getStatusIcon } from "@/lib/tasks/utils" +import { DeleteTasksDialog } from "./delete-tasks-dialog" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./tasks-table-columns" +import { TasksTableFloatingBar } from "./tasks-table-floating-bar" +import { TasksTableToolbarActions } from "./tasks-table-toolbar-actions" +import { UpdateTaskSheet } from "./update-task-sheet" + +interface TasksTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getTasks>>, + Awaited<ReturnType<typeof getTaskStatusCounts>>, + Awaited<ReturnType<typeof getTaskPriorityCounts>>, + ] + > +} + +export function TasksTable({ promises }: TasksTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }, statusCounts, priorityCounts] = + React.use(promises) + + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<Task> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<Task>[] = [ + { + id: "title", + label: "Title", + placeholder: "Filter titles...", + }, + { + id: "status", + label: "Status", + options: tasks.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + icon: getStatusIcon(status), + count: statusCounts[status], + })), + }, + { + id: "priority", + label: "Priority", + options: tasks.priority.enumValues.map((priority) => ({ + label: toSentenceCase(priority), + value: priority, + icon: getPriorityIcon(priority), + count: priorityCounts[priority], + })), + }, + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<Task>[] = [ + { + id: "code", + label: "Task", + type: "text", + }, + { + id: "title", + label: "Title", + type: "text", + }, + { + id: "label", + label: "Label", + type: "text", + }, + { + id: "status", + label: "Status", + type: "multi-select", + options: tasks.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + icon: getStatusIcon(status), + count: statusCounts[status], + })), + }, + { + id: "priority", + label: "Priority", + type: "multi-select", + options: tasks.priority.enumValues.map((priority) => ({ + label: toSentenceCase(priority), + value: priority, + icon: getPriorityIcon(priority), + count: priorityCounts[priority], + })), + }, + { + id: "createdAt", + label: "Created at", + type: "date", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => originalRow.id, + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<TasksTableFloatingBar table={table} />} + > + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <TasksTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + <UpdateTaskSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + task={rowAction?.row.original ?? null} + /> + <DeleteTasksDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + tasks={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </> + ) +} diff --git a/lib/tasks/table/update-task-sheet.tsx b/lib/tasks/table/update-task-sheet.tsx new file mode 100644 index 00000000..1f4f5aa8 --- /dev/null +++ b/lib/tasks/table/update-task-sheet.tsx @@ -0,0 +1,230 @@ +"use client" + +import * as React from "react" +import { tasks, type Task } from "@/db/schema/tasks" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Textarea } from "@/components/ui/textarea" + +import { modifiTask } from "@/lib//tasks/service" +import { updateTaskSchema, type UpdateTaskSchema } from "@/lib/tasks/validations" + +interface UpdateTaskSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + task: Task | null +} + +export function UpdateTaskSheet({ task, ...props }: UpdateTaskSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateTaskSchema>({ + resolver: zodResolver(updateTaskSchema), + defaultValues: { + title: task?.title ?? "", + label: task?.label, + status: task?.status, + priority: task?.priority, + }, + }) + + function onSubmit(input: UpdateTaskSchema) { + startUpdateTransition(async () => { + if (!task) return + + const { error } = await modifiTask({ + id: task.id, + ...input, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("Task updated") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update task</SheetTitle> + <SheetDescription> + Update the task details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>Title</FormLabel> + <FormControl> + <Textarea + placeholder="Do a kickflip" + className="resize-none" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="label" + render={({ field }) => ( + <FormItem> + <FormLabel>Label</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a label" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {tasks.label.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {tasks.status.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="priority" + render={({ field }) => ( + <FormItem> + <FormLabel>Priority</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a priority" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {tasks.priority.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} |
